feat: sqlite video player (#19792)

* feat: video player

* use remote asset id in local query

* fix: error from pre-caching beyond total assets

* fix: flipped local videos

* incorrect aspect ratio on iOS

* ignore other storage id during equals check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2025-07-09 20:04:25 +05:30 committed by GitHub
parent 51ab7498e9
commit dfe6d27bbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 758 additions and 72 deletions

View file

@ -87,7 +87,8 @@ data class PlatformAsset (
val updatedAt: Long? = null,
val width: Long? = null,
val height: Long? = null,
val durationInSeconds: Long
val durationInSeconds: Long,
val orientation: Long
)
{
companion object {
@ -100,7 +101,8 @@ data class PlatformAsset (
val width = pigeonVar_list[5] as Long?
val height = pigeonVar_list[6] as Long?
val durationInSeconds = pigeonVar_list[7] as Long
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds)
val orientation = pigeonVar_list[8] as Long
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation)
}
}
fun toList(): List<Any?> {
@ -113,6 +115,7 @@ data class PlatformAsset (
width,
height,
durationInSeconds,
orientation,
)
}
override fun equals(other: Any?): Boolean {

View file

@ -40,7 +40,8 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DURATION
MediaStore.MediaColumns.DURATION,
MediaStore.MediaColumns.ORIENTATION,
)
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@ -74,6 +75,8 @@ open class NativeSyncApiImplBase(context: Context) {
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
@ -101,6 +104,7 @@ open class NativeSyncApiImplBase(context: Context) {
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else c.getLong(durationColumn) / 1000
val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn)
val asset = PlatformAsset(
id,
@ -110,7 +114,8 @@ open class NativeSyncApiImplBase(context: Context) {
modifiedAt,
width,
height,
duration
duration,
orientation.toLong(),
)
yield(AssetResult.ValidAsset(asset, bucketId))
}

Binary file not shown.

View file

@ -138,6 +138,7 @@ struct PlatformAsset: Hashable {
var width: Int64? = nil
var height: Int64? = nil
var durationInSeconds: Int64
var orientation: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
@ -150,6 +151,7 @@ struct PlatformAsset: Hashable {
let width: Int64? = nilOrValue(pigeonVar_list[5])
let height: Int64? = nilOrValue(pigeonVar_list[6])
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
return PlatformAsset(
id: id,
@ -159,7 +161,8 @@ struct PlatformAsset: Hashable {
updatedAt: updatedAt,
width: width,
height: height,
durationInSeconds: durationInSeconds
durationInSeconds: durationInSeconds,
orientation: orientation
)
}
func toList() -> [Any?] {
@ -172,6 +175,7 @@ struct PlatformAsset: Hashable {
width,
height,
durationInSeconds,
orientation,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View file

@ -27,7 +27,8 @@ extension PHAsset {
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration)
durationInSeconds: Int64(duration),
orientation: 0
)
}
}
@ -169,7 +170,8 @@ class NativeSyncApiImpl: NativeSyncApi {
id: asset.localIdentifier,
name: "",
type: 0,
durationInSeconds: 0
durationInSeconds: 0,
orientation: 0
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue

View file

@ -25,6 +25,7 @@ sealed class BaseAsset {
final int? height;
final int? durationInSeconds;
final bool isFavorite;
final String? livePhotoVideoId;
const BaseAsset({
required this.name,
@ -36,18 +37,12 @@ sealed class BaseAsset {
this.height,
this.durationInSeconds,
this.isFavorite = false,
this.livePhotoVideoId,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
double? get aspectRatio {
if (width != null && height != null && height! > 0) {
return width! / height!;
}
return null;
}
bool get hasRemote =>
storage == AssetState.remote || storage == AssetState.merged;
bool get hasLocal =>

View file

@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset {
final String id;
final String? remoteId;
final int orientation;
const LocalAsset({
required this.id,
@ -16,6 +17,8 @@ class LocalAsset extends BaseAsset {
super.height,
super.durationInSeconds,
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
});
@override
@ -38,6 +41,7 @@ class LocalAsset extends BaseAsset {
durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite,
orientation: $orientation,
}''';
}
@ -45,11 +49,12 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other && id == other.id && remoteId == other.remoteId;
return super == other && id == other.id && orientation == other.orientation;
}
@override
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode;
int get hashCode =>
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
LocalAsset copyWith({
String? id,
@ -63,6 +68,7 @@ class LocalAsset extends BaseAsset {
int? height,
int? durationInSeconds,
bool? isFavorite,
int? orientation,
}) {
return LocalAsset(
id: id ?? this.id,
@ -76,6 +82,7 @@ class LocalAsset extends BaseAsset {
height: height ?? this.height,
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
);
}
}

View file

@ -30,6 +30,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
});
@override
@ -65,7 +66,6 @@ class RemoteAsset extends BaseAsset {
return super == other &&
id == other.id &&
ownerId == other.ownerId &&
localId == other.localId &&
thumbHash == other.thumbHash &&
visibility == other.visibility;
}

View file

@ -5,6 +5,7 @@ enum Setting<T> {
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
loadOriginal<bool>(StoreKey.loadOriginal, false),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
;

View file

@ -2,16 +2,20 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:platform/platform.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final Platform _platform;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
_localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@ -21,10 +25,40 @@ class AssetService {
}
Future<ExifInfo?> getExif(BaseAsset asset) async {
if (asset is LocalAsset || asset is! RemoteAsset) {
if (!asset.hasRemote) {
return null;
}
return _remoteAssetRepository.getExif(asset.id);
final id =
asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
return _remoteAssetRepository.getExif(id);
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = _platform.isAndroid &&
(asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
}
return 1.0;
}
}

View file

@ -61,7 +61,7 @@ class HashService {
final toHash = <_AssetToPath>[];
for (final asset in assetsToHash) {
final file = await _storageRepository.getFileForAsset(asset);
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
continue;
}

View file

@ -359,6 +359,7 @@ extension on Iterable<PlatformAsset> {
width: e.width,
height: e.height,
durationInSeconds: e.durationInSeconds,
orientation: e.orientation,
),
).toList();
}

View file

@ -184,7 +184,7 @@ class TimelineService {
// Pre-cache assets around the given index for asset viewer
Future<void> preCacheAssets(int index) =>
_mutex.run(() => _loadAssets(index, 5));
_mutex.run(() => _loadAssets(index, math.min(5, totalAssets - index)));
BaseAsset getAsset(int index) {
if (!hasRange(index, 1)) {

View file

@ -14,6 +14,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
// Only used during backup to mirror the favorite status of the asset in the server
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get orientation => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@ -31,5 +33,6 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
height: height,
width: width,
remoteId: null,
orientation: orientation,
);
}

View file

@ -16,7 +16,8 @@ mergedAsset: SELECT * FROM
rae.is_favorite,
rae.thumb_hash,
rae.checksum,
rae.owner_id
rae.owner_id,
0 as orientation
FROM
remote_asset_entity rae
LEFT JOIN
@ -37,7 +38,8 @@ mergedAsset: SELECT * FROM
lae.is_favorite,
NULL as thumb_hash,
lae.checksum,
NULL as owner_id
NULL as owner_id,
lae.orientation
FROM
local_asset_entity lae
LEFT JOIN

View file

@ -281,6 +281,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
height: Value(asset.height),
durationInSeconds: Value(asset.durationInSeconds),
id: asset.id,
orientation: Value(asset.orientation),
checksum: const Value(null),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(

View file

@ -11,7 +11,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Stream<LocalAsset?> watchAsset(String id) {
final query = _db.localAssetEntity
.select()
.addColumns([_db.localAssetEntity.id]).join([
.addColumns([_db.remoteAssetEntity.id]).join([
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),

View file

@ -1,29 +1,22 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class StorageRepository {
const StorageRepository();
Future<File?> getFileForAsset(LocalAsset asset) async {
Future<File?> getFileForAsset(String assetId) async {
final log = Logger('StorageRepository');
File? file;
try {
final entity = await AssetEntity.fromId(asset.id);
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
log.warning("Cannot get file for asset $assetId");
}
} catch (error, stackTrace) {
log.warning(
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
}

View file

@ -99,6 +99,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
height: row.height,
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
orientation: row.orientation,
);
},
).get();

Binary file not shown.

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -11,8 +12,11 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@ -78,6 +82,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {};
// Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = [];
@ -158,6 +163,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
_cancelTimers();
// This will trigger the pre-caching of adjacent assets ensuring
@ -455,11 +465,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
);
}
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
final asset = ref.read(timelineServiceProvider).getAsset(index);
final size = Size(ctx.width, ctx.height);
if (asset.isImage) {
return _imageBuilder(ctx, asset);
}
return _videoBuilder(ctx, asset);
}
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
final size = Size(ctx.width, ctx.height);
return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
@ -486,6 +510,43 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
);
}
GlobalKey _getVideoPlayerKey(String id) {
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys[id]!;
}
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center,
child: SizedBox(
width: ctx.width,
height: ctx.height,
child: NativeVideoViewer(
key: _getVideoPlayerKey(asset.heroTag),
asset: asset,
image: Image(
key: ValueKey(asset),
image:
getFullImageProvider(asset, size: Size(ctx.width, ctx.height)),
fit: BoxFit.contain,
height: ctx.height,
width: ctx.width,
alignment: Alignment.center,
),
),
),
);
}
void _onPop<T>(bool didPop, T? result) {
ref.read(currentAssetNotifier.notifier).dispose();
}
@ -518,6 +579,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,

View file

@ -1,3 +1,4 @@
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState {
@ -63,6 +64,13 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
showingBottomSheet: showing,
showingControls: showing ? true : state.showingControls,
);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
void setControls(bool isShowing) {
state = state.copyWith(showingControls: isShowing);
}
void toggleControls() {

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
const ViewerBottomBar({super.key});
@ -65,11 +66,17 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
height: 80,
height: asset.isVideo ? 160 : 80,
color: Colors.black.withAlpha(125),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: actions,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: actions,
),
],
),
),
),

View file

@ -0,0 +1,429 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
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/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/asset_viewer/current_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:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class NativeVideoViewer extends HookConsumerWidget {
final BaseAsset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetNotifier));
final isCurrent = currentAsset.value == asset;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);
final log = Logger('NativeVideoViewerPage');
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
try {
if (asset.hasLocal && asset.livePhotoVideoId == null) {
final id = asset is LocalAsset
? (asset as LocalAsset).id
: (asset as RemoteAsset).localId!;
final file = await const StorageRepository().getFileForAsset(id);
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
return source;
}
final remoteId = (asset as RemoteAsset).id;
// Use a network URL for the video player controller
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 = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe(
'Error creating video source for asset ${asset.name}: $error',
);
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(
() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value =
await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe(
'Error getting aspect ratio for asset ${asset.name}: $error',
);
}
},
[asset.heroTag],
);
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value ||
videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value =
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
final newSeek = newControls.position ~/ 1;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (newControls.pause) {
await playerController.pause();
} else {
await playerController.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
return;
}
try {
await videoController.play();
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status =
videoPlayback.state;
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
!ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loopVideo)) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged
.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null) {
return;
}
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
});
final loopVideo = ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loopVideo);
nc.setLoop(loopVideo);
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor), () {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
});
});
useEffect(
() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value
? null
: Timer(
const Duration(milliseconds: 300),
() => isVisible.value = true,
);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
},
const [],
);
useOnAppLifecycleStateChange((_, state) async {
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
controller.value?.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
controller.value?.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent
? NativeVideoPlayerView(
key: ValueKey(asset),
onViewReady: initController,
)
: null,
),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
);
}
}

View file

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const VideoViewerControls({
super.key,
this.hideTimerDuration = const Duration(seconds: 5),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(
currentAssetNotifier.select((asset) => asset != null && asset.isVideo),
);
bool showControls =
ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showBottomSheet =
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
if (showBottomSheet) {
showControls = false;
}
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(
hideTimerDuration,
() {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
// Do not hide on paused
if (state != VideoPlaybackState.paused &&
state != VideoPlaybackState.completed &&
assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
},
);
final showBuffering =
state == VideoPlaybackState.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
(previous, next) {
showControlsAndStartHideTimer();
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
if (cast.castState == CastState.playing) {
ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) {
ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) {
// resend the play command since its finished
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
return;
}
// ref.read(castProvider.notifier).loadMedia(asset, true);
}
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
)
else
GestureDetector(
onTap: () =>
ref.read(assetViewerProvider.notifier).setControls(false),
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying: state == VideoPlaybackState.playing ||
(cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
),
],
),
),
);
}
}

View file

@ -175,7 +175,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
LocalFullImageProvider key,
ImageDecoderCallback decode,
) async* {
final file = await _storageRepository.getFileForAsset(key.asset);
final file = await _storageRepository.getFileForAsset(key.asset.id);
if (file == null) {
throw StateError("Opening file for asset ${key.asset.name} failed");
}

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
@ -67,26 +68,20 @@ class DriftMemoryCard extends StatelessWidget {
} else {
return Hero(
tag: 'memory-${asset.id}',
// child: SizedBox(
// width: context.width,
// height: context.height,
// child: NativeVideoViewerPage(
// key: ValueKey(asset.id),
// asset: asset,
// showControls: false,
// playbackDelayFactor: 2,
// image: ImmichImage(
// asset,
// width: context.width,
// height: context.height,
// fit: BoxFit.contain,
// ),
// ),
// ),
child: FullImage(
asset,
fit: fit,
size: const Size(double.infinity, double.infinity),
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewer(
key: ValueKey(asset.id),
asset: asset,
showControls: false,
playbackDelayFactor: 2,
image: FullImage(
asset,
size: Size(context.width, context.height),
fit: BoxFit.contain,
),
),
),
);
}

View file

@ -23,6 +23,7 @@ class PlatformAsset {
final int? width;
final int? height;
final int durationInSeconds;
final int orientation;
const PlatformAsset({
required this.id,
@ -33,6 +34,7 @@ class PlatformAsset {
this.width,
this.height,
this.durationInSeconds = 0,
this.orientation = 0,
});
}

View file

@ -68,7 +68,7 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset))
when(() => mockStorageRepo.getFileForAsset(asset.id))
.thenAnswer((_) async => null);
await sut.hashAssets();
@ -89,7 +89,7 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset))
when(() => mockStorageRepo.getFileForAsset(asset.id))
.thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
(_) async => [hash],
@ -116,7 +116,7 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset))
when(() => mockStorageRepo.getFileForAsset(asset.id))
.thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path']))
.thenAnswer((_) async => [null]);
@ -141,7 +141,7 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset))
when(() => mockStorageRepo.getFileForAsset(asset.id))
.thenAnswer((_) async => mockFile);
final invalidHash = Uint8List.fromList([1, 2, 3]);
@ -180,9 +180,9 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1))
when(() => mockStorageRepo.getFileForAsset(asset1.id))
.thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2))
when(() => mockStorageRepo.getFileForAsset(asset2.id))
.thenAnswer((_) async => mockFile2);
final hash = Uint8List.fromList(List.generate(20, (i) => i));
@ -220,9 +220,9 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1))
when(() => mockStorageRepo.getFileForAsset(asset1.id))
.thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2))
when(() => mockStorageRepo.getFileForAsset(asset2.id))
.thenAnswer((_) async => mockFile2);
final hash = Uint8List.fromList(List.generate(20, (i) => i));
@ -252,9 +252,9 @@ void main() {
.thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id))
.thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1))
when(() => mockStorageRepo.getFileForAsset(asset1.id))
.thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2))
when(() => mockStorageRepo.getFileForAsset(asset2.id))
.thenAnswer((_) async => mockFile2);
final validHash = Uint8List.fromList(List.generate(20, (i) => i));