mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(mobile): sqlite timeline (#19197)
* wip: timeline * more segment extensions * added scrubber * refactor: timeline state * more refactors * fix scrubber segments * added remote thumb & thumbhash provider * feat: merged view * scrub / merged asset fixes * rename stuff & add tile indicators * fix local album timeline query * ignore hidden assets during sync * ignore recovered assets during sync * old scrubber * add video indicator * handle groupBy * handle partner inTimeline * show duration * reduce widget nesting in thumb tile * merge main * chore: extend cacheExtent * ignore touch events on scrub label when not visible * scrub label ignore events and hide immediately * auto reload on sync * refactor image providers * throttle db updates --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
7347f64958
commit
bcda2c6e22
50 changed files with 2921 additions and 59 deletions
|
|
@ -0,0 +1,96 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final IAssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
final LocalAsset asset;
|
||||
final double height;
|
||||
final double width;
|
||||
|
||||
LocalThumbProvider({
|
||||
required this.asset,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
LocalThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer =
|
||||
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return await decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
|
||||
key.asset.id,
|
||||
size: Size(key.width, key.height),
|
||||
);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${key.asset.name} failed",
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final double height;
|
||||
final double width;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
RemoteThumbProvider({
|
||||
required this.assetId,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
);
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'dart:convert' hide Codec;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:thumbhash/thumbhash.dart';
|
||||
|
||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||
final String thumbHash;
|
||||
|
||||
ThumbHashProvider({
|
||||
required this.thumbHash,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadCodec(key, decode),
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _loadCodec(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ThumbHashProvider) {
|
||||
return thumbHash == other.thumbHash;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => thumbHash.hashCode;
|
||||
}
|
||||
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class Thumbnail extends StatelessWidget {
|
||||
const Thumbnail({
|
||||
required this.asset,
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
|
||||
static ImageProvider imageProvider({
|
||||
required BaseAsset asset,
|
||||
Size size = const Size.square(256),
|
||||
}) {
|
||||
if (asset is LocalAsset) {
|
||||
return LocalThumbProvider(
|
||||
asset: asset,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
if (asset is Asset) {
|
||||
return RemoteThumbProvider(
|
||||
assetId: asset.id,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null;
|
||||
final provider = imageProvider(asset: asset, size: size);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
octoSet: OctoSet(
|
||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
||||
errorBuilder: _blurHashErrorBuilder(
|
||||
thumbHash,
|
||||
provider: provider,
|
||||
fit: fit,
|
||||
asset: asset,
|
||||
),
|
||||
),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
fadeInDuration: Duration.zero,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(
|
||||
String? thumbHash, {
|
||||
BoxFit? fit,
|
||||
}) {
|
||||
return (context) => thumbHash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
OctoErrorBuilder _blurHashErrorBuilder(
|
||||
String? blurhash, {
|
||||
BaseAsset? asset,
|
||||
ImageProvider? provider,
|
||||
BoxFit? fit,
|
||||
}) =>
|
||||
(context, e, s) {
|
||||
Logger("ImThumbnail")
|
||||
.warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||
provider?.evict();
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
||||
const Opacity(
|
||||
opacity: 0.75,
|
||||
child: Icon(Icons.error_outline_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
|
||||
class ThumbnailTile extends StatelessWidget {
|
||||
const ThumbnailTile(
|
||||
this.asset, {
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
this.showStorageIndicator = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)),
|
||||
if (asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _VideoIndicator(asset.durationInSeconds ?? 0),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(
|
||||
switch (asset.storage) {
|
||||
AssetState.local => Icons.cloud_off_outlined,
|
||||
AssetState.remote => Icons.cloud_outlined,
|
||||
AssetState.merged => Icons.cloud_done_outlined,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (asset.isFavorite)
|
||||
const Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(Icons.favorite_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoIndicator extends StatelessWidget {
|
||||
final int durationInSeconds;
|
||||
const _VideoIndicator(this.durationInSeconds);
|
||||
|
||||
String _formatDuration(int durationInSec) {
|
||||
final int hours = durationInSec ~/ 3600;
|
||||
final int minutes = (durationInSec % 3600) ~/ 60;
|
||||
final int seconds = durationInSec % 60;
|
||||
|
||||
final String minutesPadded = minutes.toString().padLeft(2, '0');
|
||||
final String secondsPadded = seconds.toString().padLeft(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS
|
||||
} else {
|
||||
return "$minutesPadded:$secondsPadded"; // MM:SS
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 3,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(durationInSeconds),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _TileOverlayIcon(Icons.play_circle_outline_rounded),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileOverlayIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
|
||||
const _TileOverlayIcon(this.icon);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
offset: const Offset(0.0, 0.0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue