静态礼物动画补充
This commit is contained in:
parent
afc6401e68
commit
ea99051267
@ -42,6 +42,7 @@ import 'services/shop/shop_manager.dart';
|
|||||||
import 'services/theme/theme_manager.dart';
|
import 'services/theme/theme_manager.dart';
|
||||||
import 'services/auth/user_profile_manager.dart';
|
import 'services/auth/user_profile_manager.dart';
|
||||||
import 'ui_kit/theme/socialchat_theme.dart';
|
import 'ui_kit/theme/socialchat_theme.dart';
|
||||||
|
import 'ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart';
|
||||||
import 'ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart';
|
import 'ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart';
|
||||||
|
|
||||||
bool _isCrashlyticsReady = false;
|
bool _isCrashlyticsReady = false;
|
||||||
@ -410,6 +411,16 @@ class _YumiApplicationState extends State<YumiApplication> {
|
|||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: VapPlusSvgaPlayer(tag: "room_gift"),
|
child: VapPlusSvgaPlayer(tag: "room_gift"),
|
||||||
),
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: RoomGiftSeatFlightOverlay(
|
||||||
|
controller: RoomGiftSeatFlightController(),
|
||||||
|
resolveTargetKey:
|
||||||
|
(userId) => Provider.of<RtcProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).getSeatGlobalKeyByIndex(userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
|
|||||||
import 'package:yumi/services/gift/gift_animation_manager.dart';
|
import 'package:yumi/services/gift/gift_animation_manager.dart';
|
||||||
import 'package:yumi/services/gift/gift_system_manager.dart';
|
import 'package:yumi/services/gift/gift_system_manager.dart';
|
||||||
import 'package:yumi/services/audio/rtm_manager.dart';
|
import 'package:yumi/services/audio/rtm_manager.dart';
|
||||||
|
import 'package:yumi/shared/tools/sc_path_utils.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart';
|
import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart';
|
||||||
|
import 'package:yumi/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_screen.dart';
|
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_screen.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart';
|
import 'package:yumi/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
|
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
|
||||||
@ -42,6 +44,8 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
|||||||
final List<Widget> _pages = [AllChatPage(), ChatPage(), GiftChatPage()];
|
final List<Widget> _pages = [AllChatPage(), ChatPage(), GiftChatPage()];
|
||||||
final List<Widget> _tabs = [];
|
final List<Widget> _tabs = [];
|
||||||
late StreamSubscription _subscription;
|
late StreamSubscription _subscription;
|
||||||
|
final RoomGiftSeatFlightController _giftSeatFlightController =
|
||||||
|
RoomGiftSeatFlightController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -60,6 +64,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
|||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
).toggleGiftAnimationVisibility(false);
|
).toggleGiftAnimationVisibility(false);
|
||||||
|
_giftSeatFlightController.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -70,6 +75,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
|||||||
if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) {
|
if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) {
|
||||||
rtmProvider.msgFloatingGiftListener = null;
|
rtmProvider.msgFloatingGiftListener = null;
|
||||||
}
|
}
|
||||||
|
_giftSeatFlightController.clear();
|
||||||
_tabController.dispose(); // 释放资源
|
_tabController.dispose(); // 释放资源
|
||||||
_subscription.cancel();
|
_subscription.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@ -272,5 +278,80 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
|||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
).enqueueGiftAnimation(giftModel);
|
).enqueueGiftAnimation(giftModel);
|
||||||
|
|
||||||
|
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
|
||||||
|
final targetUserId = _resolveGiftTargetUserId(msg);
|
||||||
|
if (_shouldPlaySeatFlightGiftAnimation(msg) && targetUserId != null) {
|
||||||
|
_giftSeatFlightController.enqueue(
|
||||||
|
RoomGiftSeatFlightRequest(
|
||||||
|
imagePath: giftPhoto,
|
||||||
|
targetUserId: targetUserId,
|
||||||
|
beginSize: 96.w,
|
||||||
|
endSize: 28.w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldPlaySeatFlightGiftAnimation(Msg msg) {
|
||||||
|
final gift = msg.gift;
|
||||||
|
if (gift == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final giftPhoto = (gift.giftPhoto ?? "").trim();
|
||||||
|
if (giftPhoto.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final giftPhotoExt = _normalizedGiftResourceExtension(giftPhoto);
|
||||||
|
if (_isAnimatedGiftResource(giftPhotoExt)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final giftSourceUrl = (gift.giftSourceUrl ?? "").trim();
|
||||||
|
final sourceExt = _normalizedGiftResourceExtension(giftSourceUrl);
|
||||||
|
return !_isAnimatedGiftResource(sourceExt);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAnimatedGiftResource(String extension) {
|
||||||
|
return extension == ".svga" || extension == ".mp4" || extension == ".vap";
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizedGiftResourceExtension(String resource) {
|
||||||
|
final value = resource.trim();
|
||||||
|
if (value.isEmpty) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri != null && ((uri.scheme.isNotEmpty) || (uri.host.isNotEmpty))) {
|
||||||
|
return SCPathUtils.getFileExtension(uri.path).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedValue = value.split("?").first.split("#").first;
|
||||||
|
return SCPathUtils.getFileExtension(normalizedValue).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolveGiftTargetUserId(Msg msg) {
|
||||||
|
final directUserId = (msg.toUser?.id ?? "").trim();
|
||||||
|
if (directUserId.isNotEmpty) {
|
||||||
|
return directUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetAccount = (msg.toUser?.account ?? "").trim();
|
||||||
|
if (targetAccount.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
|
||||||
|
for (final micRes in rtcProvider.roomWheatMap.values) {
|
||||||
|
if ((micRes.user?.account ?? "").trim() == targetAccount) {
|
||||||
|
final userId = (micRes.user?.id ?? "").trim();
|
||||||
|
if (userId.isNotEmpty) {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
433
lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart
Normal file
433
lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
|
||||||
|
|
||||||
|
typedef RoomGiftSeatTargetResolver =
|
||||||
|
GlobalKey<State<StatefulWidget>>? Function(String targetUserId);
|
||||||
|
|
||||||
|
class RoomGiftSeatFlightRequest {
|
||||||
|
const RoomGiftSeatFlightRequest({
|
||||||
|
required this.imagePath,
|
||||||
|
required this.targetUserId,
|
||||||
|
this.holdDuration = const Duration(milliseconds: 780),
|
||||||
|
this.flightDuration = const Duration(milliseconds: 920),
|
||||||
|
this.beginSize = 96,
|
||||||
|
this.endSize = 34,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String imagePath;
|
||||||
|
final String targetUserId;
|
||||||
|
final Duration holdDuration;
|
||||||
|
final Duration flightDuration;
|
||||||
|
final double beginSize;
|
||||||
|
final double endSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoomGiftSeatFlightController {
|
||||||
|
static final RoomGiftSeatFlightController _instance =
|
||||||
|
RoomGiftSeatFlightController._internal();
|
||||||
|
|
||||||
|
factory RoomGiftSeatFlightController() => _instance;
|
||||||
|
|
||||||
|
RoomGiftSeatFlightController._internal();
|
||||||
|
|
||||||
|
final Queue<RoomGiftSeatFlightRequest> _pendingRequests = Queue();
|
||||||
|
_RoomGiftSeatFlightOverlayState? _state;
|
||||||
|
|
||||||
|
void enqueue(RoomGiftSeatFlightRequest request) {
|
||||||
|
final imagePath = request.imagePath.trim();
|
||||||
|
final targetUserId = request.targetUserId.trim();
|
||||||
|
if (imagePath.isEmpty || targetUserId.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedRequest = RoomGiftSeatFlightRequest(
|
||||||
|
imagePath: imagePath,
|
||||||
|
targetUserId: targetUserId,
|
||||||
|
holdDuration: request.holdDuration,
|
||||||
|
flightDuration: request.flightDuration,
|
||||||
|
beginSize: request.beginSize,
|
||||||
|
endSize: request.endSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_state == null) {
|
||||||
|
_pendingRequests.add(normalizedRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state!._enqueue(normalizedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_pendingRequests.clear();
|
||||||
|
_state?._clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attach(_RoomGiftSeatFlightOverlayState state) {
|
||||||
|
_state = state;
|
||||||
|
while (_pendingRequests.isNotEmpty) {
|
||||||
|
state._enqueue(_pendingRequests.removeFirst());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _detach(_RoomGiftSeatFlightOverlayState state) {
|
||||||
|
if (_state == state) {
|
||||||
|
_state = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoomGiftSeatFlightOverlay extends StatefulWidget {
|
||||||
|
const RoomGiftSeatFlightOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.resolveTargetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RoomGiftSeatFlightController controller;
|
||||||
|
final RoomGiftSeatTargetResolver resolveTargetKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RoomGiftSeatFlightOverlay> createState() =>
|
||||||
|
_RoomGiftSeatFlightOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final Queue<_QueuedRoomGiftSeatFlightRequest> _queue = Queue();
|
||||||
|
final GlobalKey _overlayKey = GlobalKey();
|
||||||
|
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
RoomGiftSeatFlightRequest? _activeRequest;
|
||||||
|
ImageProvider<Object>? _activeImageProvider;
|
||||||
|
Offset? _activeTargetOffset;
|
||||||
|
bool _isPlaying = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(vsync: this)..addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
|
_finishCurrentAnimation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
widget.controller._attach(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant RoomGiftSeatFlightOverlay oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.controller != widget.controller) {
|
||||||
|
oldWidget.controller._detach(this);
|
||||||
|
widget.controller._attach(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller._detach(this);
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enqueue(RoomGiftSeatFlightRequest request) {
|
||||||
|
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
|
||||||
|
_scheduleNextAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clear() {
|
||||||
|
_queue.clear();
|
||||||
|
_controller.stop();
|
||||||
|
_controller.reset();
|
||||||
|
if (!mounted) {
|
||||||
|
_activeRequest = null;
|
||||||
|
_activeImageProvider = null;
|
||||||
|
_activeTargetOffset = null;
|
||||||
|
_isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_activeRequest = null;
|
||||||
|
_activeImageProvider = null;
|
||||||
|
_activeTargetOffset = null;
|
||||||
|
_isPlaying = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleNextAnimation() {
|
||||||
|
if (_isPlaying || _queue.isEmpty || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_startNextAnimation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startNextAnimation() async {
|
||||||
|
if (!mounted || _isPlaying || _queue.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queuedRequest = _queue.removeFirst();
|
||||||
|
final targetOffset = _resolveTargetOffset(
|
||||||
|
queuedRequest.request.targetUserId,
|
||||||
|
);
|
||||||
|
if (targetOffset == null) {
|
||||||
|
if (queuedRequest.retryCount < 6) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 120), () {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_queue.addFirst(
|
||||||
|
queuedRequest.copyWith(retryCount: queuedRequest.retryCount + 1),
|
||||||
|
);
|
||||||
|
_scheduleNextAnimation();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_scheduleNextAnimation();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageProvider = _resolveImageProvider(
|
||||||
|
queuedRequest.request.imagePath,
|
||||||
|
);
|
||||||
|
_isPlaying = true;
|
||||||
|
_controller.duration =
|
||||||
|
queuedRequest.request.holdDuration +
|
||||||
|
queuedRequest.request.flightDuration;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeRequest = queuedRequest.request;
|
||||||
|
_activeTargetOffset = targetOffset;
|
||||||
|
_activeImageProvider = imageProvider;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await precacheImage(
|
||||||
|
imageProvider,
|
||||||
|
context,
|
||||||
|
).timeout(const Duration(milliseconds: 250));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (!mounted || _activeRequest != queuedRequest.request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller.reset();
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finishCurrentAnimation() {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_activeRequest = null;
|
||||||
|
_activeImageProvider = null;
|
||||||
|
_activeTargetOffset = null;
|
||||||
|
_isPlaying = false;
|
||||||
|
});
|
||||||
|
_scheduleNextAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset? _resolveTargetOffset(String targetUserId) {
|
||||||
|
final overlayContext = _overlayKey.currentContext;
|
||||||
|
final targetKey = widget.resolveTargetKey(targetUserId);
|
||||||
|
final targetContext = targetKey?.currentContext;
|
||||||
|
if (overlayContext == null || targetContext == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final overlayBox = overlayContext.findRenderObject();
|
||||||
|
final targetBox = targetContext.findRenderObject();
|
||||||
|
if (overlayBox is! RenderBox ||
|
||||||
|
targetBox is! RenderBox ||
|
||||||
|
!overlayBox.hasSize ||
|
||||||
|
!targetBox.hasSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final globalTargetCenter = targetBox.localToGlobal(
|
||||||
|
targetBox.size.center(Offset.zero),
|
||||||
|
);
|
||||||
|
return overlayBox.globalToLocal(globalTargetCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageProvider<Object> _resolveImageProvider(String imagePath) {
|
||||||
|
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||||
|
return buildCachedImageProvider(imagePath);
|
||||||
|
}
|
||||||
|
if (imagePath.startsWith('/')) {
|
||||||
|
return FileImage(File(imagePath));
|
||||||
|
}
|
||||||
|
return AssetImage(imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: SizedBox.expand(
|
||||||
|
key: _overlayKey,
|
||||||
|
child:
|
||||||
|
_activeRequest == null ||
|
||||||
|
_activeImageProvider == null ||
|
||||||
|
_activeTargetOffset == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
final renderBox =
|
||||||
|
_overlayKey.currentContext?.findRenderObject()
|
||||||
|
as RenderBox?;
|
||||||
|
final overlaySize =
|
||||||
|
renderBox?.size ?? MediaQuery.sizeOf(context);
|
||||||
|
final center = overlaySize.center(Offset.zero);
|
||||||
|
final request = _activeRequest!;
|
||||||
|
final totalDuration =
|
||||||
|
request.holdDuration + request.flightDuration;
|
||||||
|
final holdRatio =
|
||||||
|
totalDuration.inMilliseconds == 0
|
||||||
|
? 0.0
|
||||||
|
: request.holdDuration.inMilliseconds /
|
||||||
|
totalDuration.inMilliseconds;
|
||||||
|
final progress = _controller.value;
|
||||||
|
|
||||||
|
if (progress <= holdRatio) {
|
||||||
|
final normalizedHold =
|
||||||
|
holdRatio == 0
|
||||||
|
? 1.0
|
||||||
|
: (progress / holdRatio).clamp(0.0, 1.0);
|
||||||
|
final pulseScale =
|
||||||
|
1 + math.sin(normalizedHold * math.pi) * 0.05;
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
_buildGiftNode(
|
||||||
|
center: center,
|
||||||
|
size: request.beginSize,
|
||||||
|
opacity: 1,
|
||||||
|
scale: pulseScale,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final flightProgress = ((progress - holdRatio) /
|
||||||
|
(1 - holdRatio))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
final travelProgress = Curves.easeInOutCubic.transform(
|
||||||
|
flightProgress,
|
||||||
|
);
|
||||||
|
const trailGaps = <double>[0, 0.16, 0.32];
|
||||||
|
const trailOpacities = <double>[1, 0.5, 0.24];
|
||||||
|
const trailScales = <double>[1, 0.92, 0.84];
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children:
|
||||||
|
List.generate(trailGaps.length, (index) {
|
||||||
|
final trailingProgress = (travelProgress -
|
||||||
|
trailGaps[index])
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
final nodeCenter =
|
||||||
|
Offset.lerp(
|
||||||
|
center,
|
||||||
|
_activeTargetOffset,
|
||||||
|
trailingProgress,
|
||||||
|
)!;
|
||||||
|
final nodeSize =
|
||||||
|
lerpDouble(
|
||||||
|
request.beginSize,
|
||||||
|
request.endSize,
|
||||||
|
trailingProgress,
|
||||||
|
)!;
|
||||||
|
final fadeOut =
|
||||||
|
1 -
|
||||||
|
Curves.easeOut.transform(
|
||||||
|
((travelProgress - 0.88) / 0.12).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return _buildGiftNode(
|
||||||
|
center: nodeCenter,
|
||||||
|
size: nodeSize,
|
||||||
|
opacity: trailOpacities[index] * fadeOut,
|
||||||
|
scale: trailScales[index],
|
||||||
|
);
|
||||||
|
}).reversed.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGiftNode({
|
||||||
|
required Offset center,
|
||||||
|
required double size,
|
||||||
|
required double opacity,
|
||||||
|
double scale = 1,
|
||||||
|
}) {
|
||||||
|
if (_activeImageProvider == null || opacity <= 0 || size <= 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: center.dx - size / 2,
|
||||||
|
top: center.dy - size / 2,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: opacity.clamp(0.0, 1.0),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Image(
|
||||||
|
image: _activeImageProvider!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
filterQuality: FilterQuality.low,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedRoomGiftSeatFlightRequest {
|
||||||
|
const _QueuedRoomGiftSeatFlightRequest({
|
||||||
|
required this.request,
|
||||||
|
this.retryCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RoomGiftSeatFlightRequest request;
|
||||||
|
final int retryCount;
|
||||||
|
|
||||||
|
_QueuedRoomGiftSeatFlightRequest copyWith({int? retryCount}) {
|
||||||
|
return _QueuedRoomGiftSeatFlightRequest(
|
||||||
|
request: request,
|
||||||
|
retryCount: retryCount ?? this.retryCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
需求进度.md
3
需求进度.md
@ -13,6 +13,9 @@
|
|||||||
- 本轮按需求暂未处理网络链路上的启动等待,例如审核态检查或远端启动页配置请求。
|
- 本轮按需求暂未处理网络链路上的启动等待,例如审核态检查或远端启动页配置请求。
|
||||||
|
|
||||||
## 已完成模块
|
## 已完成模块
|
||||||
|
- 已将语言房送礼链路接入新的“中心停留后飞向目标麦位”组件,但只对无自带特效的静态 PNG 礼物生效:当前带自身 `SVGA/MP4/VAP` 动画或被识别为全屏礼物特效的礼物保持原有播放逻辑不变;只有普通 PNG 礼物会额外触发“屏幕中央停留 -> 三连残影飞向被赠送麦位”的补充动效,避免和自带礼物特效重复叠播。
|
||||||
|
- 已继续收敛语言房送礼飞行动效的命中条件:上一版对普通礼物的过滤过严,既依赖 `giftPhoto` 必须显式以 `.png` 结尾,也会被部分礼物的 `special` 标记误伤,导致不少实际没有自带动画的礼物被提前跳过;当前已改为只排除真实带 `SVGA/MP4/VAP` 动画源的礼物,普通静态封面礼物即使是带 query 的图片 URL、或不是严格 `.png` 后缀,也会正常触发“中心停留 -> 飞向目标麦位”的补充动画。
|
||||||
|
- 已将语言房送礼飞行动画从房间页内部 `Stack` 提升到应用根层,挂载方式对齐现有 `SVGA/VAP` 礼物特效层:当前该动画会和 `VapPlusSvgaPlayer` 一样在 `main.dart` 的顶层 builder 中全屏绘制,因此不会再被房间内部聊天区、局部动效或页面层级压住,视觉上更靠前、更容易被用户看到。
|
||||||
- 已修复礼物页幸运礼物发送链路:当前 `gift_page` 会按礼物类型分流,普通礼物继续走 `/gift/batch`,`LUCKY_GIFT/LUCK/MAGIC` 改走已存在的 `/gift/give/lucky-gift`;幸运/魔法礼物成功后不再本地伪造普通 `GIFT` 消息,而是改发房间内 `LUCK_GIFT_ANIM_OTHER` 动画消息,避免和后端幸运礼物开奖/补发逻辑打架;同时送礼失败已补用户提示,且对 `standardId` 缺失的幸运礼物增加了前置拦截,避免继续点发送却只有日志没有反馈。
|
- 已修复礼物页幸运礼物发送链路:当前 `gift_page` 会按礼物类型分流,普通礼物继续走 `/gift/batch`,`LUCKY_GIFT/LUCK/MAGIC` 改走已存在的 `/gift/give/lucky-gift`;幸运/魔法礼物成功后不再本地伪造普通 `GIFT` 消息,而是改发房间内 `LUCK_GIFT_ANIM_OTHER` 动画消息,避免和后端幸运礼物开奖/补发逻辑打架;同时送礼失败已补用户提示,且对 `standardId` 缺失的幸运礼物增加了前置拦截,避免继续点发送却只有日志没有反馈。
|
||||||
- 已继续补齐幸运礼物前端可见反馈:根据真机日志确认 `giveLuckyGift` 和腾讯 IM 房间消息发送都已成功,当前问题收敛为发送端没有本地回显、且 `LUCK_GIFT_ANIM_OTHER` 收到后未真正接入房间礼物动画;现已在发送成功后先本地触发一轮房间礼物上飘反馈,并把收到的 `LUCK_GIFT_ANIM_OTHER` 直接接入现有房间礼物动画 listener,确保发送端和房间内其它端都能看到即时反应。排障期间临时加过的发送并发锁已在后续需求确认后撤回,不再限制幸运礼物连点。
|
- 已继续补齐幸运礼物前端可见反馈:根据真机日志确认 `giveLuckyGift` 和腾讯 IM 房间消息发送都已成功,当前问题收敛为发送端没有本地回显、且 `LUCK_GIFT_ANIM_OTHER` 收到后未真正接入房间礼物动画;现已在发送成功后先本地触发一轮房间礼物上飘反馈,并把收到的 `LUCK_GIFT_ANIM_OTHER` 直接接入现有房间礼物动画 listener,确保发送端和房间内其它端都能看到即时反应。排障期间临时加过的发送并发锁已在后续需求确认后撤回,不再限制幸运礼物连点。
|
||||||
- 已按最新确认调整幸运礼物点击策略:撤回前一轮为排障临时加上的发送并发锁,恢复幸运礼物可连续点击;当前连点时每次请求仍走 `/gift/give/lucky-gift`,并继续通过本地回显 + `LUCK_GIFT_ANIM_OTHER` 接入现有房间礼物动画 listener 的方式提供与普通礼物一致的即时播报体验。
|
- 已按最新确认调整幸运礼物点击策略:撤回前一轮为排障临时加上的发送并发锁,恢复幸运礼物可连续点击;当前连点时每次请求仍走 `/gift/give/lucky-gift`,并继续通过本地回显 + `LUCK_GIFT_ANIM_OTHER` 接入现有房间礼物动画 listener 的方式提供与普通礼物一致的即时播报体验。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user