信息卡
This commit is contained in:
parent
07624ac105
commit
fac8f9fbab
561
lib/ui_kit/widgets/props/sc_adaptive_data_card_background.dart
Normal file
561
lib/ui_kit/widgets/props/sc_adaptive_data_card_background.dart
Normal file
@ -0,0 +1,561 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
|
||||
import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart';
|
||||
|
||||
class SCAdaptiveDataCardBackground extends StatefulWidget {
|
||||
const SCAdaptiveDataCardBackground({
|
||||
super.key,
|
||||
required this.resource,
|
||||
required this.sheetHeight,
|
||||
required this.fallbackBackgroundHeight,
|
||||
this.fallbackTranslateFactor = 0.8,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
final PropsResources resource;
|
||||
final double sheetHeight;
|
||||
final double fallbackBackgroundHeight;
|
||||
final double fallbackTranslateFactor;
|
||||
final Widget? fallback;
|
||||
|
||||
@override
|
||||
State<SCAdaptiveDataCardBackground> createState() =>
|
||||
_SCAdaptiveDataCardBackgroundState();
|
||||
}
|
||||
|
||||
class _SCAdaptiveDataCardBackgroundState
|
||||
extends State<SCAdaptiveDataCardBackground> {
|
||||
SCDataCardVisibleBounds? _bounds;
|
||||
Object? _cacheIdentity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBounds();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SCAdaptiveDataCardBackground oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final identity = SCDataCardBoundsDetector.identity(widget.resource);
|
||||
if (_cacheIdentity != identity) {
|
||||
_bounds = null;
|
||||
_loadBounds();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadBounds() async {
|
||||
final identity = SCDataCardBoundsDetector.identity(widget.resource);
|
||||
_cacheIdentity = identity;
|
||||
final bounds = await SCDataCardBoundsDetector.detect(widget.resource);
|
||||
if (!mounted || _cacheIdentity != identity) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_bounds = bounds;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = _positiveOrFallback(
|
||||
constraints.maxWidth,
|
||||
MediaQuery.of(context).size.width,
|
||||
);
|
||||
final metrics = _resolveMetrics(availableWidth);
|
||||
final resourceView = SCDataCardResourceView(
|
||||
resource: widget.resource,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fit: BoxFit.fill,
|
||||
fallback: widget.fallback ?? const SizedBox.expand(),
|
||||
showCoverFallback: false,
|
||||
allowCoverAsResource: false,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: metrics.left,
|
||||
top: metrics.top,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
child: IgnorePointer(child: resourceView),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_DataCardLayoutMetrics _resolveMetrics(double availableWidth) {
|
||||
final sheetHeight = math.max(widget.sheetHeight, 1.0);
|
||||
final bounds = _bounds;
|
||||
if (bounds == null || !bounds.isUsable) {
|
||||
final height = math.max(widget.fallbackBackgroundHeight, sheetHeight);
|
||||
final top =
|
||||
-(height - sheetHeight) +
|
||||
sheetHeight * widget.fallbackTranslateFactor;
|
||||
return _DataCardLayoutMetrics(
|
||||
left: 0,
|
||||
top: top,
|
||||
width: availableWidth,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
|
||||
final aspectRatio = bounds.imageWidth / bounds.imageHeight;
|
||||
final visibleHeightRatio = math.max(bounds.bottom - bounds.top, 0.08);
|
||||
final widthFitHeight = availableWidth / aspectRatio;
|
||||
final visibleFitHeight = sheetHeight / visibleHeightRatio;
|
||||
final height = math.max(widthFitHeight, visibleFitHeight);
|
||||
final width = height * aspectRatio;
|
||||
final minTop = sheetHeight - height;
|
||||
final top = (-height * bounds.top).clamp(minTop, 0.0).toDouble();
|
||||
|
||||
return _DataCardLayoutMetrics(
|
||||
left: (availableWidth - width) / 2,
|
||||
top: top,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
|
||||
double _positiveOrFallback(double value, double fallback) {
|
||||
if (value.isFinite && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
class _DataCardLayoutMetrics {
|
||||
const _DataCardLayoutMetrics({
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final double left;
|
||||
final double top;
|
||||
final double width;
|
||||
final double height;
|
||||
}
|
||||
|
||||
class SCDataCardBoundsDetector {
|
||||
static const _cachePrefix = 'sc_data_card_visible_bounds_v1';
|
||||
static const _targetThumbnailWidth = 240;
|
||||
static const _backgroundDistanceThreshold = 38;
|
||||
|
||||
static final Map<String, Future<SCDataCardVisibleBounds?>> _inFlight = {};
|
||||
static final Map<String, SCDataCardVisibleBounds> _memory = {};
|
||||
|
||||
static String identity(PropsResources resource) {
|
||||
final parts = <Object?>[
|
||||
resource.id,
|
||||
resource.updateTime,
|
||||
resource.sourceUrl,
|
||||
resource.cover,
|
||||
];
|
||||
return parts.map((part) => part?.toString().trim() ?? '').join('|');
|
||||
}
|
||||
|
||||
static Future<SCDataCardVisibleBounds?> detect(
|
||||
PropsResources resource,
|
||||
) async {
|
||||
final key = '$_cachePrefix:${identity(resource)}';
|
||||
final memory = _memory[key];
|
||||
if (memory != null) {
|
||||
return memory;
|
||||
}
|
||||
final running = _inFlight[key];
|
||||
if (running != null) {
|
||||
return running;
|
||||
}
|
||||
|
||||
final future = _detectAndCache(resource, key);
|
||||
_inFlight[key] = future;
|
||||
try {
|
||||
return await future;
|
||||
} finally {
|
||||
_inFlight.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SCDataCardVisibleBounds?> _detectAndCache(
|
||||
PropsResources resource,
|
||||
String key,
|
||||
) async {
|
||||
final cached = await _readCache(key);
|
||||
if (cached != null) {
|
||||
_memory[key] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
final bytes = await _thumbnailBytes(resource);
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final bounds = await _detectVisibleBounds(bytes);
|
||||
if (bounds == null) {
|
||||
return null;
|
||||
}
|
||||
_memory[key] = bounds;
|
||||
await _writeCache(key, bounds);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
static Future<SCDataCardVisibleBounds?> _readCache(String key) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(key);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return SCDataCardVisibleBounds.fromJson(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _writeCache(
|
||||
String key,
|
||||
SCDataCardVisibleBounds bounds,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(key, jsonEncode(bounds.toJson()));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _thumbnailBytes(PropsResources resource) async {
|
||||
final source = _firstNonBlank([resource.sourceUrl]);
|
||||
if (_isVideo(source)) {
|
||||
final videoBytes = await _videoThumbnailBytes(source);
|
||||
if (videoBytes != null && videoBytes.isNotEmpty) {
|
||||
return videoBytes;
|
||||
}
|
||||
}
|
||||
|
||||
final cover = _firstNonBlank([resource.cover]);
|
||||
final imageUrl = _firstNonBlank([cover, source]);
|
||||
if (_isImage(imageUrl)) {
|
||||
return _imageBytes(imageUrl);
|
||||
}
|
||||
if (_isVideo(imageUrl)) {
|
||||
return _videoThumbnailBytes(imageUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _videoThumbnailBytes(String url) async {
|
||||
try {
|
||||
return await VideoThumbnail.thumbnailData(
|
||||
video: url,
|
||||
imageFormat: ImageFormat.PNG,
|
||||
maxWidth: _targetThumbnailWidth,
|
||||
timeMs: 500,
|
||||
quality: 80,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _imageBytes(String url) async {
|
||||
try {
|
||||
final file = await _resourceFile(url);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
return file.readAsBytes();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<File?> _resourceFile(String url) async {
|
||||
final text = url.trim();
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (text.startsWith('http://') || text.startsWith('https://')) {
|
||||
return DefaultCacheManager().getSingleFile(text);
|
||||
}
|
||||
if (text.startsWith('file://')) {
|
||||
return File.fromUri(Uri.parse(text));
|
||||
}
|
||||
return File(text);
|
||||
}
|
||||
|
||||
static Future<SCDataCardVisibleBounds?> _detectVisibleBounds(
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
ui.Codec? codec;
|
||||
ui.Image? image;
|
||||
try {
|
||||
codec = await ui.instantiateImageCodec(
|
||||
bytes,
|
||||
targetWidth: _targetThumbnailWidth,
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
image = frame.image;
|
||||
final byteData = await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
);
|
||||
if (byteData == null) {
|
||||
return null;
|
||||
}
|
||||
return _scanRgba(
|
||||
byteData.buffer.asUint8List(),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
image?.dispose();
|
||||
codec?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static SCDataCardVisibleBounds? _scanRgba(
|
||||
Uint8List rgba,
|
||||
int width,
|
||||
int height,
|
||||
) {
|
||||
if (width <= 1 || height <= 1) {
|
||||
return null;
|
||||
}
|
||||
final background = _sampleBackgroundColor(rgba, width, height);
|
||||
if (background == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final thresholdSq =
|
||||
_backgroundDistanceThreshold * _backgroundDistanceThreshold;
|
||||
final stride = width > 180 || height > 180 ? 2 : 1;
|
||||
var minX = width;
|
||||
var minY = height;
|
||||
var maxX = -1;
|
||||
var maxY = -1;
|
||||
|
||||
for (var y = 0; y < height; y += stride) {
|
||||
for (var x = 0; x < width; x += stride) {
|
||||
final offset = ((y * width) + x) * 4;
|
||||
final alpha = rgba[offset + 3];
|
||||
if (alpha <= 18) {
|
||||
continue;
|
||||
}
|
||||
final dr = rgba[offset] - background.red;
|
||||
final dg = rgba[offset + 1] - background.green;
|
||||
final db = rgba[offset + 2] - background.blue;
|
||||
final distanceSq = dr * dr + dg * dg + db * db;
|
||||
final visibleByAlpha = alpha < 230;
|
||||
if (distanceSq <= thresholdSq && !visibleByAlpha) {
|
||||
continue;
|
||||
}
|
||||
minX = math.min(minX, x);
|
||||
minY = math.min(minY, y);
|
||||
maxX = math.max(maxX, x);
|
||||
maxY = math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxX < minX || maxY < minY) {
|
||||
return null;
|
||||
}
|
||||
final visibleWidth = maxX - minX + 1;
|
||||
final visibleHeight = maxY - minY + 1;
|
||||
final areaRatio = (visibleWidth * visibleHeight) / (width * height);
|
||||
if (areaRatio < 0.08 || areaRatio > 0.98) {
|
||||
return SCDataCardVisibleBounds.full(width: width, height: height);
|
||||
}
|
||||
|
||||
return SCDataCardVisibleBounds(
|
||||
imageWidth: width.toDouble(),
|
||||
imageHeight: height.toDouble(),
|
||||
left: minX / width,
|
||||
top: minY / height,
|
||||
right: ((maxX + 1) / width).clamp(0.0, 1.0),
|
||||
bottom: ((maxY + 1) / height).clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
static _Rgb? _sampleBackgroundColor(Uint8List rgba, int width, int height) {
|
||||
final insetX = math.max(1, (width * 0.08).round());
|
||||
final insetY = math.max(1, (height * 0.08).round());
|
||||
final points = <_Rgb>[];
|
||||
|
||||
void addPoint(int x, int y) {
|
||||
final offset = ((y * width) + x) * 4;
|
||||
if (rgba[offset + 3] <= 18) {
|
||||
return;
|
||||
}
|
||||
points.add(_Rgb(rgba[offset], rgba[offset + 1], rgba[offset + 2]));
|
||||
}
|
||||
|
||||
for (var y = 0; y < insetY; y += 2) {
|
||||
for (var x = 0; x < insetX; x += 2) {
|
||||
addPoint(x, y);
|
||||
addPoint(width - 1 - x, y);
|
||||
addPoint(x, height - 1 - y);
|
||||
addPoint(width - 1 - x, height - 1 - y);
|
||||
}
|
||||
}
|
||||
|
||||
if (points.isEmpty) {
|
||||
return const _Rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
for (final point in points) {
|
||||
red += point.red;
|
||||
green += point.green;
|
||||
blue += point.blue;
|
||||
}
|
||||
return _Rgb(
|
||||
(red / points.length).round(),
|
||||
(green / points.length).round(),
|
||||
(blue / points.length).round(),
|
||||
);
|
||||
}
|
||||
|
||||
static String _firstNonBlank(Iterable<String?> values) {
|
||||
for (final value in values) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static bool _isVideo(String url) {
|
||||
final lower = url.trim().toLowerCase();
|
||||
return _hasExtension(lower, const ['.mp4', '.vap', '.mov', '.m4v']);
|
||||
}
|
||||
|
||||
static bool _isImage(String url) {
|
||||
final lower = url.trim().toLowerCase();
|
||||
return _hasExtension(lower, const [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
]);
|
||||
}
|
||||
|
||||
static bool _hasExtension(String url, List<String> extensions) {
|
||||
if (url.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return extensions.any(
|
||||
(extension) =>
|
||||
url.endsWith(extension) ||
|
||||
url.contains('$extension?') ||
|
||||
url.contains('$extension&'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SCDataCardVisibleBounds {
|
||||
const SCDataCardVisibleBounds({
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.right,
|
||||
required this.bottom,
|
||||
});
|
||||
|
||||
factory SCDataCardVisibleBounds.full({
|
||||
required int width,
|
||||
required int height,
|
||||
}) {
|
||||
return SCDataCardVisibleBounds(
|
||||
imageWidth: width.toDouble(),
|
||||
imageHeight: height.toDouble(),
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1,
|
||||
bottom: 1,
|
||||
);
|
||||
}
|
||||
|
||||
factory SCDataCardVisibleBounds.fromJson(dynamic json) {
|
||||
final map = json as Map;
|
||||
return SCDataCardVisibleBounds(
|
||||
imageWidth: _readDouble(map['imageWidth']),
|
||||
imageHeight: _readDouble(map['imageHeight']),
|
||||
left: _readDouble(map['left']),
|
||||
top: _readDouble(map['top']),
|
||||
right: _readDouble(map['right']),
|
||||
bottom: _readDouble(map['bottom']),
|
||||
);
|
||||
}
|
||||
|
||||
final double imageWidth;
|
||||
final double imageHeight;
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
bool get isUsable {
|
||||
return imageWidth > 0 &&
|
||||
imageHeight > 0 &&
|
||||
right > left &&
|
||||
bottom > top &&
|
||||
left >= 0 &&
|
||||
top >= 0 &&
|
||||
right <= 1 &&
|
||||
bottom <= 1;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'imageWidth': imageWidth,
|
||||
'imageHeight': imageHeight,
|
||||
'left': left,
|
||||
'top': top,
|
||||
'right': right,
|
||||
'bottom': bottom,
|
||||
};
|
||||
}
|
||||
|
||||
static double _readDouble(dynamic value) {
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
return double.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
class _Rgb {
|
||||
const _Rgb(this.red, this.green, this.blue);
|
||||
|
||||
final int red;
|
||||
final int green;
|
||||
final int blue;
|
||||
}
|
||||
@ -38,7 +38,7 @@ import 'package:yumi/services/auth/user_profile_manager.dart';
|
||||
import 'package:yumi/modules/gift/gift_page.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
|
||||
import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart';
|
||||
import 'package:yumi/ui_kit/widgets/props/sc_adaptive_data_card_background.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/cp/sc_cp_corner_label.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart';
|
||||
|
||||
@ -338,11 +338,14 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
-(backgroundHeight - sheetHeight) +
|
||||
sheetHeight * _dataCardBackgroundTranslateFactor,
|
||||
height: backgroundHeight,
|
||||
child: IgnorePointer(child: _buildSheetBackground(dataCard)),
|
||||
top: 0,
|
||||
height: sheetHeight,
|
||||
child: SCAdaptiveDataCardBackground(
|
||||
resource: dataCard,
|
||||
sheetHeight: sheetHeight,
|
||||
fallbackBackgroundHeight: backgroundHeight,
|
||||
fallbackTranslateFactor: _dataCardBackgroundTranslateFactor,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
@ -501,19 +504,6 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSheetBackground(PropsResources? dataCard) {
|
||||
if (dataCard == null) {
|
||||
return _buildDefaultSheetBackground();
|
||||
}
|
||||
|
||||
return SCDataCardResourceView(
|
||||
resource: dataCard,
|
||||
fit: BoxFit.cover,
|
||||
showCoverFallback: false,
|
||||
allowCoverAsResource: false,
|
||||
);
|
||||
}
|
||||
|
||||
PropsResources? _activeDataCard(room_card.RoomUserCardRes? cardInfo) {
|
||||
return _activeDataCardFromUseProps(cardInfo?.userProfile?.useProps) ??
|
||||
_activeDataCardFromUseProps(cardInfo?.useProps);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user