mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 09:00:58 +03:00
feat(mobile): show animated images in asset viewer (#26614)
* Add support for showing animated images in AssetViewer with AnimatedImageStreamCompleter * Add GIF overlay to thumbnail tile for animated assets * formatting * require isAnimated parameter in image providers for better asset handling * feat: refactor AnimatedImageStreamCompleter to use streams for codec loading and initial image handling * formatting * add isAnimatedImage property to BaseAsset * remove ApiService.getRequestHeaders() usage
This commit is contained in:
@@ -46,6 +46,7 @@ sealed class BaseAsset {
|
|||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
|
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
||||||
|
|
||||||
AssetPlaybackStyle get playbackStyle {
|
AssetPlaybackStyle get playbackStyle {
|
||||||
if (isVideo) return AssetPlaybackStyle.video;
|
if (isVideo) return AssetPlaybackStyle.video;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show InformationCollector;
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
|
||||||
|
/// which makes resource cleanup possible when no longer needed.
|
||||||
|
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
|
||||||
|
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||||
|
void Function()? _onLastListenerRemoved;
|
||||||
|
int _listenerCount = 0;
|
||||||
|
// True once any image or the codec has been provided.
|
||||||
|
// Until then the image cache holds one listener, so "last real listener gone"
|
||||||
|
// is _listenerCount == 1, not 0.
|
||||||
|
bool didProvideImage = false;
|
||||||
|
|
||||||
|
AnimatedImageStreamCompleter._({
|
||||||
|
required super.codec,
|
||||||
|
required super.scale,
|
||||||
|
super.informationCollector,
|
||||||
|
void Function()? onLastListenerRemoved,
|
||||||
|
}) : _onLastListenerRemoved = onLastListenerRemoved;
|
||||||
|
|
||||||
|
factory AnimatedImageStreamCompleter({
|
||||||
|
required Stream<Object> stream,
|
||||||
|
required double scale,
|
||||||
|
ImageInfo? initialImage,
|
||||||
|
InformationCollector? informationCollector,
|
||||||
|
void Function()? onLastListenerRemoved,
|
||||||
|
}) {
|
||||||
|
final codecCompleter = Completer<ui.Codec>();
|
||||||
|
final self = AnimatedImageStreamCompleter._(
|
||||||
|
codec: codecCompleter.future,
|
||||||
|
scale: scale,
|
||||||
|
informationCollector: informationCollector,
|
||||||
|
onLastListenerRemoved: onLastListenerRemoved,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialImage != null) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
self.setImage(initialImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.listen(
|
||||||
|
(item) {
|
||||||
|
if (item is ImageInfo) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
self.setImage(item);
|
||||||
|
} else if (item is ui.Codec) {
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
codecCompleter.complete(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace stack) {
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
codecCompleter.completeError(error, stack);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
// also complete if we are done but no error occurred, and we didn't call complete yet
|
||||||
|
// could happen on cancellation
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void addListener(ImageStreamListener listener) {
|
||||||
|
super.addListener(listener);
|
||||||
|
_listenerCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void removeListener(ImageStreamListener listener) {
|
||||||
|
super.removeListener(listener);
|
||||||
|
_listenerCount--;
|
||||||
|
|
||||||
|
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||||
|
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
|
||||||
|
|
||||||
|
if (onlyCacheListenerLeft || noListenersAfterCodec) {
|
||||||
|
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||||
|
if (onLastListenerRemoved != null) {
|
||||||
|
_onLastListenerRemoved = null;
|
||||||
|
onLastListenerRemoved();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
|||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
if (_shouldUseLocalAsset(asset)) {
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
|
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||||
} else {
|
} else {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final String thumbhash;
|
final String thumbhash;
|
||||||
@@ -153,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
|||||||
} else {
|
} else {
|
||||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||||
}
|
}
|
||||||
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
|
provider = RemoteFullImageProvider(
|
||||||
|
assetId: assetId,
|
||||||
|
thumbhash: thumbhash,
|
||||||
|
assetType: asset.type,
|
||||||
|
isAnimated: asset.isAnimatedImage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
@@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
final String id;
|
final String id;
|
||||||
final Size size;
|
final Size size;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
final bool isAnimated;
|
||||||
|
|
||||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
|
if (key.isAnimated) {
|
||||||
|
return AnimatedImageStreamCompleter(
|
||||||
|
stream: _animatedCodec(key, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||||
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
|
],
|
||||||
|
onLastListenerRemoved: cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||||
@@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<Size>('Size', key.size),
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
],
|
],
|
||||||
onLastListenerRemoved: cancel,
|
onLastListenerRemoved: cancel,
|
||||||
);
|
);
|
||||||
@@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
yield* loadRequest(request, decode);
|
yield* loadRequest(request, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
|
yield* initialImageStream();
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
|
final previewRequest = request = LocalImageRequest(
|
||||||
|
localId: key.id,
|
||||||
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
|
assetType: key.assetType,
|
||||||
|
);
|
||||||
|
yield* loadRequest(previewRequest, decode);
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always try original for animated, since previews don't support animation
|
||||||
|
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
|
||||||
|
final codec = await loadCodecRequest(originalRequest);
|
||||||
|
if (codec == null) {
|
||||||
|
throw StateError('Failed to load animated codec for local asset ${key.id}');
|
||||||
|
}
|
||||||
|
yield codec;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is LocalFullImageProvider) {
|
if (other is LocalFullImageProvider) {
|
||||||
return id == other.id && size == other.size;
|
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode ^ size.hashCode;
|
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
@@ -58,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
final String assetId;
|
final String assetId;
|
||||||
final String thumbhash;
|
final String thumbhash;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
final bool isAnimated;
|
||||||
|
|
||||||
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
|
RemoteFullImageProvider({
|
||||||
|
required this.assetId,
|
||||||
|
required this.thumbhash,
|
||||||
|
required this.assetType,
|
||||||
|
required this.isAnimated,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -68,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
|
if (key.isAnimated) {
|
||||||
|
return AnimatedImageStreamCompleter(
|
||||||
|
stream: _animatedCodec(key, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||||
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
|
],
|
||||||
|
onLastListenerRemoved: cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
],
|
],
|
||||||
onLastListenerRemoved: cancel,
|
onLastListenerRemoved: cancel,
|
||||||
);
|
);
|
||||||
@@ -106,16 +128,43 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
yield* loadRequest(originalRequest, decode);
|
yield* loadRequest(originalRequest, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
|
yield* initialImageStream();
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewRequest = request = RemoteImageRequest(
|
||||||
|
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||||
|
);
|
||||||
|
yield* loadRequest(previewRequest, decode, evictOnError: false);
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always try original for animated, since previews don't support animation
|
||||||
|
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
|
||||||
|
final codec = await loadCodecRequest(originalRequest);
|
||||||
|
if (codec == null) {
|
||||||
|
throw StateError('Failed to load animated codec for asset ${key.assetId}');
|
||||||
|
}
|
||||||
|
yield codec;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is RemoteFullImageProvider) {
|
if (other is RemoteFullImageProvider) {
|
||||||
return assetId == other.assetId && thumbhash == other.thumbhash;
|
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
|
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget {
|
|||||||
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||||
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
||||||
),
|
),
|
||||||
|
if (asset.isAnimatedImage)
|
||||||
|
const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ class ImmichImage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
|
return RemoteFullImageProvider(
|
||||||
|
assetId: assetId!,
|
||||||
|
thumbhash: '',
|
||||||
|
assetType: base_asset.AssetType.video,
|
||||||
|
isAnimated: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useLocal(asset)) {
|
if (useLocal(asset)) {
|
||||||
@@ -43,12 +48,14 @@ class ImmichImage extends StatelessWidget {
|
|||||||
id: asset.localId!,
|
id: asset.localId!,
|
||||||
assetType: base_asset.AssetType.video,
|
assetType: base_asset.AssetType.video,
|
||||||
size: Size(width, height),
|
size: Size(width, height),
|
||||||
|
isAnimated: false,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return RemoteFullImageProvider(
|
return RemoteFullImageProvider(
|
||||||
assetId: asset.remoteId!,
|
assetId: asset.remoteId!,
|
||||||
thumbhash: asset.thumbhash ?? '',
|
thumbhash: asset.thumbhash ?? '',
|
||||||
assetType: base_asset.AssetType.video,
|
assetType: base_asset.AssetType.video,
|
||||||
|
isAnimated: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user