信息卡

This commit is contained in:
zhx 2026-05-25 22:40:36 +08:00
parent 07624ac105
commit fac8f9fbab
2 changed files with 570 additions and 19 deletions

View 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;
}

View File

@ -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);