mirror of
https://github.com/immich-app/immich.git
synced 2025-01-17 01:06:46 +01:00
make aspect ratio logic reusable, optimizations
This commit is contained in:
parent
60715059f7
commit
4d1d902773
4 changed files with 166 additions and 115 deletions
|
@ -89,6 +89,34 @@ class Asset {
|
||||||
|
|
||||||
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool _didUpdateLocal = false;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get didUpdateLocal => _didUpdateLocal;
|
||||||
|
|
||||||
|
Future<AssetEntity> get localAsync async {
|
||||||
|
final currentLocal = local;
|
||||||
|
if (currentLocal == null) {
|
||||||
|
throw Exception('Asset $fileName has no local data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_didUpdateLocal) {
|
||||||
|
return currentLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedLocal = _didUpdateLocal
|
||||||
|
? currentLocal
|
||||||
|
: await currentLocal.obtainForNewProperties();
|
||||||
|
if (updatedLocal == null) {
|
||||||
|
throw Exception('Could not fetch local data for $fileName');
|
||||||
|
}
|
||||||
|
|
||||||
|
local = updatedLocal;
|
||||||
|
_didUpdateLocal = true;
|
||||||
|
return updatedLocal;
|
||||||
|
}
|
||||||
|
|
||||||
Id id = Isar.autoIncrement;
|
Id id = Isar.autoIncrement;
|
||||||
|
|
||||||
/// stores the raw SHA1 bytes as a base64 String
|
/// stores the raw SHA1 bytes as a base64 String
|
||||||
|
@ -147,9 +175,36 @@ class Asset {
|
||||||
int stackCount;
|
int stackCount;
|
||||||
|
|
||||||
/// Aspect ratio of the asset
|
/// Aspect ratio of the asset
|
||||||
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
@ignore
|
@ignore
|
||||||
double? get aspectRatio =>
|
double? get aspectRatio {
|
||||||
width == null || height == null ? 0 : width! / height!;
|
late final double? orientatedWidth;
|
||||||
|
late final double? orientatedHeight;
|
||||||
|
|
||||||
|
if (exifInfo != null) {
|
||||||
|
orientatedWidth = this.orientatedWidth?.toDouble();
|
||||||
|
orientatedHeight = this.orientatedHeight?.toDouble();
|
||||||
|
} else if (didUpdateLocal) {
|
||||||
|
final currentLocal = local;
|
||||||
|
if (currentLocal == null) {
|
||||||
|
throw Exception('Asset $fileName has no local data');
|
||||||
|
}
|
||||||
|
orientatedWidth = currentLocal.orientatedWidth.toDouble();
|
||||||
|
orientatedHeight = currentLocal.orientatedHeight.toDouble();
|
||||||
|
} else {
|
||||||
|
orientatedWidth = null;
|
||||||
|
orientatedHeight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientatedWidth != null &&
|
||||||
|
orientatedHeight != null &&
|
||||||
|
orientatedWidth > 0 &&
|
||||||
|
orientatedHeight > 0) {
|
||||||
|
return orientatedWidth / orientatedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// `true` if this [Asset] is present on the device
|
/// `true` if this [Asset] is present on the device
|
||||||
@ignore
|
@ignore
|
||||||
|
|
|
@ -42,7 +42,7 @@ class GalleryStackedChildren extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
key: ValueKey(currentAsset),
|
key: ValueKey(currentAsset.id),
|
||||||
padding: const EdgeInsets.only(right: 5),
|
padding: const EdgeInsets.only(right: 5),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|
|
@ -49,9 +49,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
final lastVideoPosition = useRef(-1);
|
final lastVideoPosition = useRef(-1);
|
||||||
final isBuffering = useRef(false);
|
final isBuffering = useRef(false);
|
||||||
|
|
||||||
if (isPlayingMotionVideo != null) {
|
useListenable(isPlayingMotionVideo);
|
||||||
useListenable(isPlayingMotionVideo);
|
|
||||||
}
|
|
||||||
final showMotionVideo =
|
final showMotionVideo =
|
||||||
isPlayingMotionVideo != null && isPlayingMotionVideo!.value;
|
isPlayingMotionVideo != null && isPlayingMotionVideo!.value;
|
||||||
|
|
||||||
|
@ -62,124 +60,67 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
final currentAsset = useState(ref.read(currentAssetProvider));
|
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||||
final isCurrent = currentAsset.value == asset;
|
final isCurrent = currentAsset.value == asset;
|
||||||
|
|
||||||
// used to show the placeholder during hero animations for remote videos to avoid a stutter
|
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||||
final isVisible = useState(asset.isLocal || asset.isMotionPhoto);
|
final isVisible = useState(asset.isLocal || asset.isMotionPhoto);
|
||||||
|
|
||||||
final log = Logger('NativeVideoViewerPage');
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
|
||||||
final localEntity = useMemoized(() {
|
|
||||||
if (!asset.isLocal || asset.isMotionPhoto) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final local = asset.local;
|
|
||||||
if (local == null || local.orientation > 0) {
|
|
||||||
return Future.value(local);
|
|
||||||
}
|
|
||||||
|
|
||||||
return local.obtainForNewProperties();
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<double?> calculateAspectRatio() async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
late final double? orientatedWidth;
|
|
||||||
late final double? orientatedHeight;
|
|
||||||
|
|
||||||
if (asset.exifInfo != null) {
|
|
||||||
orientatedWidth = asset.orientatedWidth?.toDouble();
|
|
||||||
orientatedHeight = asset.orientatedHeight?.toDouble();
|
|
||||||
} else if (localEntity != null) {
|
|
||||||
final entity = await localEntity;
|
|
||||||
if (entity != null) {
|
|
||||||
asset.local = entity;
|
|
||||||
orientatedWidth = entity.orientatedWidth.toDouble();
|
|
||||||
orientatedHeight = entity.orientatedHeight.toDouble();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final entity = await ref.read(assetServiceProvider).loadExif(asset);
|
|
||||||
orientatedWidth = entity.orientatedWidth?.toDouble();
|
|
||||||
orientatedHeight = entity.orientatedHeight?.toDouble();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orientatedWidth != null &&
|
|
||||||
orientatedHeight != null &&
|
|
||||||
orientatedWidth > 0 &&
|
|
||||||
orientatedHeight > 0) {
|
|
||||||
return orientatedWidth / orientatedHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<VideoSource?> createSource() async {
|
Future<VideoSource?> createSource() async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localEntity != null) {
|
try {
|
||||||
final file = await (await localEntity)!.file;
|
final local = asset.local;
|
||||||
if (file == null) {
|
if (local != null && !asset.isMotionPhoto) {
|
||||||
throw Exception('No file found for the video');
|
final file = await local.file;
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||||
|
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
final source = await VideoSource.init(
|
||||||
path: file.path,
|
path: videoUrl,
|
||||||
type: VideoSourceType.file,
|
type: VideoSourceType.network,
|
||||||
|
headers: ApiService.getRequestHeaders(),
|
||||||
);
|
);
|
||||||
return source;
|
return source;
|
||||||
|
} catch (error) {
|
||||||
|
log.severe(
|
||||||
|
'Error creating video source for asset ${asset.fileName}: $error',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a network URL for the video player controller
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
|
||||||
final String videoUrl = asset.livePhotoVideoId != null
|
|
||||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
|
||||||
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
|
||||||
path: videoUrl,
|
|
||||||
type: VideoSourceType.network,
|
|
||||||
headers: ApiService.getRequestHeaders(),
|
|
||||||
);
|
|
||||||
return source;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoSource = useState<VideoSource?>(null);
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
final aspectRatio = useState<double?>(null);
|
final aspectRatio = useState<double?>(asset.aspectRatio);
|
||||||
useMemoized(
|
useMemoized(
|
||||||
() async {
|
() async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted || aspectRatio.value != null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
late final VideoSource? videoSourceRes;
|
|
||||||
late final double? aspectRatioRes;
|
|
||||||
try {
|
try {
|
||||||
(videoSourceRes, aspectRatioRes) =
|
aspectRatio.value =
|
||||||
await (createSource(), calculateAspectRatio()).wait;
|
await ref.read(assetServiceProvider).getAspectRatio(asset);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe(
|
log.severe(
|
||||||
'Error initializing video for asset ${asset.fileName}: $error',
|
'Error getting aspect ratio for asset ${asset.fileName}: $error',
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoSourceRes == null || aspectRatioRes == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if opening a remote video from a hero animation, delay visibility to avoid a stutter
|
|
||||||
if (!asset.isLocal && isCurrent) {
|
|
||||||
Timer(
|
|
||||||
const Duration(milliseconds: 200),
|
|
||||||
() => isVisible.value = true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoSource.value = videoSourceRes;
|
|
||||||
aspectRatio.value = aspectRatioRes;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -197,7 +138,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// timer to mark videos as buffering if the position does not change
|
// Timer to mark videos as buffering if the position does not change
|
||||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||||
|
|
||||||
// When the volume changes, set the volume
|
// When the volume changes, set the volume
|
||||||
|
@ -286,7 +227,11 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await videoController.play();
|
if (asset.isVideo ||
|
||||||
|
isPlayingMotionVideo == null ||
|
||||||
|
isPlayingMotionVideo!.value) {
|
||||||
|
await videoController.play();
|
||||||
|
}
|
||||||
await videoController.setVolume(0.9);
|
await videoController.setVolume(0.9);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error playing video: $error');
|
log.severe('Error playing video: $error');
|
||||||
|
@ -362,6 +307,24 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onToggleMotionVideo() async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isPlayingMotionVideo!.value) {
|
||||||
|
await videoController.seekTo(0);
|
||||||
|
await videoController.play();
|
||||||
|
} else {
|
||||||
|
await videoController.pause();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error toggling motion video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void removeListeners(NativeVideoPlayerController controller) {
|
void removeListeners(NativeVideoPlayerController controller) {
|
||||||
controller.onPlaybackPositionChanged
|
controller.onPlaybackPositionChanged
|
||||||
.removeListener(onPlaybackPositionChanged);
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
@ -371,19 +334,24 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||||
}
|
}
|
||||||
|
|
||||||
void initController(NativeVideoPlayerController nc) {
|
void initController(NativeVideoPlayerController nc) async {
|
||||||
if (controller.value != null) {
|
if (controller.value != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
|
||||||
|
final source = await videoSource;
|
||||||
|
if (source == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||||
nc.loadVideoSource(videoSource.value!);
|
|
||||||
|
|
||||||
|
nc.loadVideoSource(source);
|
||||||
controller.value = nc;
|
controller.value = nc;
|
||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
}
|
}
|
||||||
|
@ -399,7 +367,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no need to delay video playback when swiping from an image to a video
|
// No need to delay video playback when swiping from an image to a video
|
||||||
if (curAsset != null && !curAsset.isVideo) {
|
if (curAsset != null && !curAsset.isVideo) {
|
||||||
currentAsset.value = value;
|
currentAsset.value = value;
|
||||||
onPlaybackReady();
|
onPlaybackReady();
|
||||||
|
@ -421,7 +389,24 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
|
|
||||||
useEffect(
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlayingMotionVideo != null) {
|
||||||
|
isPlayingMotionVideo!.addListener(onToggleMotionVideo);
|
||||||
|
}
|
||||||
|
|
||||||
return () {
|
return () {
|
||||||
|
timer?.cancel();
|
||||||
|
if (isPlayingMotionVideo != null) {
|
||||||
|
isPlayingMotionVideo!.removeListener(onToggleMotionVideo);
|
||||||
|
}
|
||||||
|
|
||||||
final playerController = controller.value;
|
final playerController = controller.value;
|
||||||
if (playerController == null) {
|
if (playerController == null) {
|
||||||
return;
|
return;
|
||||||
|
@ -442,23 +427,24 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
// This remains under the video to avoid flickering
|
// This remains under the video to avoid flickering
|
||||||
// For motion videos, this is the image portion of the asset
|
// For motion videos, this is the image portion of the asset
|
||||||
Center(key: ValueKey(asset.id), child: image),
|
Center(key: ValueKey(asset.id), child: image),
|
||||||
Visibility.maintain(
|
if (aspectRatio.value != null)
|
||||||
key: ValueKey(asset),
|
Visibility.maintain(
|
||||||
visible: (asset.isVideo || showMotionVideo) && isVisible.value,
|
|
||||||
child: Center(
|
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset),
|
||||||
child: AspectRatio(
|
visible: (asset.isVideo || showMotionVideo) && isVisible.value,
|
||||||
|
child: Center(
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset),
|
||||||
aspectRatio: aspectRatio.value!,
|
child: AspectRatio(
|
||||||
child: isCurrent
|
key: ValueKey(asset),
|
||||||
? NativeVideoPlayerView(
|
aspectRatio: aspectRatio.value!,
|
||||||
key: ValueKey(asset),
|
child: isCurrent
|
||||||
onViewReady: initController,
|
? NativeVideoPlayerView(
|
||||||
)
|
key: ValueKey(asset),
|
||||||
: null,
|
onViewReady: initController,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
@ -402,4 +402,14 @@ class AssetService {
|
||||||
|
|
||||||
return exifInfo?.description ?? "";
|
return exifInfo?.description ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<double> getAspectRatio(Asset asset) async {
|
||||||
|
if (asset.isLocal) {
|
||||||
|
await asset.localAsync;
|
||||||
|
} else if (asset.isRemote) {
|
||||||
|
asset = await loadExif(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset.aspectRatio ?? 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue