diff --git a/lib/ui_kit/widgets/props/sc_adaptive_data_card_background.dart b/lib/ui_kit/widgets/props/sc_adaptive_data_card_background.dart new file mode 100644 index 0000000..36b292a --- /dev/null +++ b/lib/ui_kit/widgets/props/sc_adaptive_data_card_background.dart @@ -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 createState() => + _SCAdaptiveDataCardBackgroundState(); +} + +class _SCAdaptiveDataCardBackgroundState + extends State { + 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 _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> _inFlight = {}; + static final Map _memory = {}; + + static String identity(PropsResources resource) { + final parts = [ + resource.id, + resource.updateTime, + resource.sourceUrl, + resource.cover, + ]; + return parts.map((part) => part?.toString().trim() ?? '').join('|'); + } + + static Future 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 _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 _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 _writeCache( + String key, + SCDataCardVisibleBounds bounds, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, jsonEncode(bounds.toJson())); + } catch (_) {} + } + + static Future _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 _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 _imageBytes(String url) async { + try { + final file = await _resourceFile(url); + if (file == null) { + return null; + } + return file.readAsBytes(); + } catch (_) { + return null; + } + } + + static Future _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 _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 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 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 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; +} diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index 3129d86..dd04730 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -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 { 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 { ); } - 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);