Merge branch 'main' of gitea.haiyihy.com:hy/chatapp3-flutter

This commit is contained in:
hy 2026-04-22 14:30:51 +08:00
commit 15ac93c737
39 changed files with 5360 additions and 3607 deletions

View File

@ -0,0 +1,335 @@
# Voice Room Emoji Package Plan
## Goal
This document only records a feasible implementation plan. No business code is changed in this round.
Requirement summary:
- Add an emoji package button next to the voice-room chat entry.
- The emoji package button and text chat must open the same bottom composer sheet.
- Emoji packages are shown only on mic seats.
- Emoji packages must not appear in room chat messages, including the `All`, `Chat`, and `Gift` tabs.
- Wait for final backend data and UI assets before starting the real implementation.
## Current Codebase Status
The current project already has several reusable pieces, so the feature is feasible and does not need a brand-new architecture.
Reusable entry points:
- Bottom area layout: `lib/ui_kit/widgets/room/room_bottom_widget.dart`
- Chat composer popup: `lib/ui_kit/widgets/room/room_msg_input.dart`
- Seat emoji display: `lib/modules/room/seat/sc_seat_item.dart`
- Room RTM send/receive: `lib/services/audio/rtm_manager.dart`
- Seat transient state: `lib/services/audio/rtc_manager.dart`
- Seat model: `lib/shared/business_logic/models/res/mic_res.dart`
- Emoji material API: `lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart`
- Emoji material model: `lib/shared/business_logic/models/res/sc_room_emoji_res.dart`
- Existing room message type: `lib/app/constants/sc_room_msg_type.dart`
Confirmed reusable behavior in current code:
- `SCRoomMsgType.emoticons` already exists.
- The room receiver already handles `EMOTICONS` separately and calls `RtcProvider.starPlayEmoji(msg)`.
- `SCSeatItem` already has an `Emoticons` widget with queueing, fade animation, and auto-dismiss.
- The project already has `/material/emoji/all`, and the response model already supports category + emoji list.
## Recommended Architecture
Recommended direction: keep one composer sheet and support two entry modes.
Recommended entry modes:
- `text` mode: opened from the existing chat entry, keyboard focused by default.
- `emojiPackage` mode: opened from the new emoji package button, keyboard hidden by default, emoji package panel open by default.
Recommended widget strategy:
- Reuse `RoomMsgInput` instead of creating a second bottom sheet.
- Add an input parameter such as `initialMode` or `initialPanel`.
- Keep the text input area as the same top row.
- Use the lower content area to switch between text helper emoji and room emoji package content.
Why this is the best fit:
- It matches the requirement that text chat and emoji package use the same bottom popup.
- It keeps interaction and style in one place.
- It avoids future drift between two different room composer implementations.
## Recommended Interaction Flow
### 1. Bottom bar
Add a new emoji package button next to the room chat entry in `RoomBottomWidget`.
Recommended behavior:
- Tap chat entry: open `RoomMsgInput(initialMode: text)`.
- Tap emoji package button: open `RoomMsgInput(initialMode: emojiPackage)`.
- If the current user is not on mic, the emoji package button can either be disabled or show a toast such as "Take a mic first".
Important layout note:
- `RoomBottomWidget` currently uses hard-coded width math for the bottom bar and for `_resolveGiftCenterX(...)`.
- After adding one more action button, the row layout and gift floating button center calculation both need to be updated together.
- If only the row is changed and `_resolveGiftCenterX(...)` is not updated, the floating gift button can become visually misaligned.
### 2. Composer sheet
Recommended sheet structure:
- Top area: existing input field, send button, and keyboard/emoji switch logic.
- Bottom area in `text` mode: current text emoji helper can remain for typing unicode emoji.
- Bottom area in `emojiPackage` mode: category tabs + emoji package grid from backend data.
Recommended send interaction for emoji package:
- Tapping an emoji package item sends immediately.
- The sheet should stay open after sending so the user can send multiple emoji packages continuously.
- If UI later wants tap-to-close, that can be a product choice, but continuous sending is the safer default for room interaction.
### 3. Seat-only display
Emoji package messages should not enter room chat lists.
Recommended rule:
- Text messages still follow the existing room chat path.
- Emoji package clicks trigger seat animation only.
- No new item should be inserted into `roomAllMsgList`, `roomChatMsgList`, or `roomGiftMsgList`.
## Recommended Send Path
### Recommended payload shape
The smallest-change plan is to reuse the existing room custom message structure:
```dart
Msg(
groupId: roomAccount,
type: SCRoomMsgType.emoticons,
msg: emoji.sourceUrl,
number: currentSeatIndex,
user: currentUserProfile,
role: currentUserRole,
)
```
Recommended field meaning:
- `type`: `EMOTICONS`
- `msg`: selected emoji resource URL
- `number`: current seat index
- `user`: sender profile, for consistency with existing room messages
### Why not send it as a normal chat message
Because `RtmProvider.addMsg(...)` always inserts the message into `roomAllMsgList` first.
Current receive side is already good enough:
- In `_newGroupMsg(...)`, `EMOTICONS` goes directly to `RtcProvider.starPlayEmoji(msg)` and then `return`.
- That means remote users will not see emoji package content in room chat.
Current sender side still needs care during implementation:
- If the sender uses `dispatchMessage(...)` with default `addLocal: true`, the local sender will still push one item into `roomAllMsgList`.
- That would violate the requirement that emoji packages must not show in chat.
Recommended implementation behavior later:
- Build the `Msg`.
- First do a local optimistic seat display by calling `RtcProvider.starPlayEmoji(msg)`.
- Then call `dispatchMessage(msg, addLocal: false)`.
This gives the sender instant seat feedback while keeping the chat list clean.
## Recommended Seat Resolution Rule
When sending an emoji package, determine the seat index at send time, not when the sheet is opened.
Recommended lookup:
- Use `RtcProvider.userOnMaiInIndex(currentUserId)` when the user taps an emoji item.
Reason:
- The user may switch mic seats after opening the popup.
- Using the latest seat index avoids showing the emoji on the wrong seat.
If the returned seat index is `-1`:
- Do not send.
- Show a toast telling the user that only users on mic can use emoji packages.
## Backend Dependency Assessment
### Material data
The project already has `/material/emoji/all` and the local model already supports:
- category name
- category cover
- category-level ownership state
- category-level amount
- emoji list
- emoji cover URL
- emoji source URL
This means the current backend structure is already close to the requirement shown in the design.
Backend items to confirm before development:
- Whether `/material/emoji/all` is the final interface for this feature
- Whether category ordering is controlled by backend
- Whether each category has a tab icon or cover ready for UI
- Whether `coverUrl` is the correct image for the grid cell
- Whether `sourceUrl` is the real image used on the mic seat
- Whether ownership is category-level only or item-level
If ownership later becomes item-level, the current model may need extension because it currently looks category-oriented.
### No extra send API is strictly required
For the minimal-change version, no new HTTP send API is needed.
Recommended transport:
- Use the existing room RTM custom message channel.
- Reuse `EMOTICONS` as the message type.
This keeps the feature aligned with the current room event architecture.
## Optional Sync Decision
There are two possible product definitions for emoji package state:
### Option A: transient RTM-only state
Behavior:
- Emoji package is only an instant seat effect.
- Users who join mid-animation do not need to recover the current emoji state.
Pros:
- Smallest frontend change
- No need for backend seat-state persistence
- Best fit for the current codebase
Recommended default:
- Yes. This should be the first implementation choice unless product explicitly wants state recovery.
### Option B: recoverable seat state
Behavior:
- If a user joins the room late or reconnects, they can still see the currently active emoji package on the seat during its valid duration.
If product wants this, backend and model changes are needed:
- mic list response needs to include active emoji state
- active emoji state should include at least resource URL and expiration timing
- frontend model parsing needs to be updated
Important current gap:
- `MicRes.fromJson(...)` does not parse `emojiPath`
- `MicRes.toJson()` also does not serialize `emojiPath`
So if the final backend plan depends on seat polling or room re-entry recovery, this model area will need a small follow-up patch during implementation.
## Current Code Caveats To Remember
### 1. `RoomMsgInput` is text-first today
Current behavior:
- It always autofocuses the text field.
- It only supports the local text emoji helper based on `SCEmojiDatas.smileys`.
Meaning for future implementation:
- It needs a mode parameter so opening from the emoji package button can skip keyboard autofocus and show the package panel first.
### 2. Seat emoji display currently uses an implicit field contract
Current behavior in `RtcProvider.starPlayEmoji(msg)`:
- It writes `emojiPath: msg.msg`
- It writes `type: msg.type`
- It writes `number: msg.msg`
Current behavior in `SCSeatItem.Emoticons`:
- For dice and RPS, `number` is treated like a result value
- For other types, `number` is treated like an image URL
Meaning:
- The current image emoji path works because the code uses a field-reuse convention.
- Later implementation should stay compatible with this path unless the seat overlay model is cleaned up in one pass.
### 3. Emoji cache utility exists but is not wired into the room sheet yet
`SCAppGeneralManager` already has:
- `emojiByTab`
- `emojiAll()`
But this is not currently connected to the room composer.
Recommended later choice:
- Either reuse `SCAppGeneralManager` for caching
- Or load emoji package data directly inside the room sheet and cache locally there
Either is feasible. Reusing the manager is better if the same materials are used in multiple room surfaces.
## Suggested Development Sequence Later
When backend and UI are ready, the development order should be:
1. Add the new emoji package button in `RoomBottomWidget` and adjust layout math together with `_resolveGiftCenterX(...)`.
2. Refactor `RoomMsgInput` into one shared room composer with `text` and `emojiPackage` entry modes.
3. Wire the sheet to load emoji categories and emoji items from `/material/emoji/all`.
4. Add on-mic gating and resolve seat index at click time.
5. On emoji package click, do local optimistic seat playback.
6. Send RTM `EMOTICONS` with `addLocal: false`.
7. Verify that `All`, `Chat`, and `Gift` tabs remain unchanged after sending.
8. If product wants reconnect recovery, then patch `MicRes` parsing and seat sync logic.
## Acceptance Checklist
- New emoji package button appears next to the room chat entry.
- Chat entry and emoji package button open the same bottom composer sheet.
- Opening from chat goes to text mode.
- Opening from emoji package goes to emoji package mode.
- Only users on mic can send emoji packages.
- Sender sees the emoji package on their own seat immediately.
- Other users see the emoji package on the sender's seat.
- Emoji package sending does not create new rows in `All`, `Chat`, or `Gift`.
- Existing text chat sending is not affected.
- Existing gift floating button remains visually centered after the new button is added.
- Repeated emoji package taps queue and play correctly on the seat.
## Final Recommendation
This feature is feasible with low-to-medium implementation risk because the project already has the key building blocks:
- shared room composer entry
- room RTM custom message channel
- `EMOTICONS` message type
- seat emoji playback widget
- emoji material API
Recommended final direction:
- Reuse the existing room composer popup
- Reuse `EMOTICONS` as the transport type
- Use seat-index-based RTM payload
- Do local optimistic seat playback
- Do not insert emoji package messages into any chat list
- Treat backend seat-state recovery as optional, not mandatory

View File

@ -502,7 +502,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = S9X2AJ2US9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -693,7 +693,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@ -410,7 +410,8 @@ class _YumiApplicationState extends State<YumiApplication> {
if (SCGlobalConfig.allowsHighCostAnimations)
Consumer<RtcProvider>(
builder: (context, rtcProvider, _) {
if (!rtcProvider.roomVisualEffectsEnabled) {
if (!rtcProvider
.shouldShowRoomVisualEffects) {
return const SizedBox.shrink();
}
return const Positioned.fill(
@ -420,15 +421,24 @@ class _YumiApplicationState extends State<YumiApplication> {
);
},
),
Positioned.fill(
child: RoomGiftSeatFlightOverlay(
controller: RoomGiftSeatFlightController(),
resolveTargetKey:
(userId) => Provider.of<RtcProvider>(
context,
listen: false,
).getSeatGlobalKeyByIndex(userId),
),
Consumer<RtcProvider>(
builder: (context, rtcProvider, _) {
if (!rtcProvider
.shouldShowRoomVisualEffects) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: RoomGiftSeatFlightOverlay(
controller:
RoomGiftSeatFlightController(),
resolveTargetKey:
(userId) => Provider.of<RtcProvider>(
context,
listen: false,
).getSeatGlobalKeyByIndex(userId),
),
);
},
),
],
);

View File

@ -21,6 +21,7 @@ import '../../../ui_kit/components/sc_debounce_widget.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/dialog/dialog_base.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../main.dart';
import '../../index/main_route.dart';
@ -153,10 +154,41 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
SizedBox(height: 5.w),
Row(
children: [
text(
"ID:${ref.currenRoom?.roomProfile?.userProfile?.getID()}",
fontSize: 12.sp,
textColor: Colors.black,
SCSpecialIdBadge(
idText:
ref
.currenRoom
?.roomProfile
?.roomProfile
?.roomAccount ??
"",
showAnimated:
(ref
.currenRoom
?.roomProfile
?.roomProfile
?.roomAccount
?.isNotEmpty ??
false),
assetPath:
SCSpecialIdAssets.roomId,
animationWidth: 124.w,
animationHeight: 30.w,
textPadding: EdgeInsets.fromLTRB(
36.w,
7.w,
18.w,
7.w,
),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
),
),
SizedBox(width: 3.w),
GestureDetector(
@ -278,10 +310,44 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
),
Row(
children: [
text(
"ID:${ref.currenRoom?.roomProfile?.userProfile?.account}",
textColor: Colors.black,
fontSize: 14.sp,
SCSpecialIdBadge(
idText:
ref
.currenRoom
?.roomProfile
?.userProfile
?.getID() ??
"",
showAnimated:
ref
.currenRoom
?.roomProfile
?.userProfile
?.hasSpecialId() ??
false,
assetPath:
SCSpecialIdAssets
.roomOwnerId,
animationWidth: 132.w,
animationHeight: 32.w,
textPadding:
EdgeInsets.fromLTRB(
40.w,
8.w,
18.w,
8.w,
),
animationTextStyle:
TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight:
FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 14.sp,
),
),
SizedBox(width: 3.w),
GestureDetector(
@ -304,7 +370,7 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
.currenRoom
?.roomProfile
?.userProfile
?.account ??
?.getID() ??
"",
),
);

View File

@ -12,6 +12,7 @@ import '../../../ui_kit/components/sc_compontent.dart';
import '../../../ui_kit/components/sc_page_list.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../shared/tools/sc_lk_dialog_util.dart';
import '../../../ui_kit/widgets/room/room_user_info_card.dart';
@ -148,22 +149,26 @@ class _RoomOnlinePageState
GestureDetector(
child: Container(
padding: EdgeInsets.symmetric(vertical: 3.w),
child: Row(
textDirection: TextDirection.ltr,
children: [
text(
"ID:${userInfo.getID()}",
fontSize: 12.sp,
textColor: Colors.black,
fontWeight: FontWeight.bold,
),
SizedBox(width: 5.w),
Image.asset(
"sc_images/room/sc_icon_user_card_copy_id.png",
width: 12.w,
height: 12.w,
),
],
child: SCSpecialIdBadge(
idText: userInfo.getID(),
showAnimated: userInfo.hasSpecialId(),
assetPath: SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding: EdgeInsets.fromLTRB(33.w, 6.w, 16.w, 6.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
showCopyIcon: true,
copyIconSize: 12.w,
copyIconSpacing: 5.w,
),
),
onTap: () {

View File

@ -28,6 +28,7 @@ class SCSeatItem extends StatefulWidget {
class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
static const String _seatHeartbeatValueIconAsset =
"sc_images/room/sc_icon_room_seat_heartbeat_value.png";
static const double _seatHeaddressScaleMultiplier = 1.3;
RtcProvider? provider;
_SeatRenderSnapshot? _cachedSnapshot;
@ -56,6 +57,8 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
window.locale.languageCode == "ar"
? ""
: seatSnapshot.headdressSourceUrl;
final double seatBaseSize = widget.isGameModel ? 40.w : 55.w;
final bool hasHeaddress = resolvedHeaddress.isNotEmpty;
return GestureDetector(
behavior: HitTestBehavior.opaque,
@ -66,9 +69,10 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
children: [
SizedBox(
key: _targetKey,
width: widget.isGameModel ? 40.w : 55.w,
height: widget.isGameModel ? 40.w : 55.w,
width: seatBaseSize,
height: seatBaseSize,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Sonic(
@ -77,10 +81,15 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
isGameModel: widget.isGameModel,
),
seatSnapshot.hasUser
? head(
url: seatSnapshot.userAvatar,
width: widget.isGameModel ? 40.w : 55.w,
headdress: resolvedHeaddress,
? Transform.scale(
scale:
hasHeaddress ? _seatHeaddressScaleMultiplier : 1,
child: head(
url: seatSnapshot.userAvatar,
width: seatBaseSize,
height: seatBaseSize,
headdress: resolvedHeaddress,
),
)
: (seatSnapshot.micLock
? Image.asset(
@ -231,7 +240,7 @@ class _SeatRenderSnapshot {
});
factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) {
final roomSeat = provider.roomWheatMap[index];
final roomSeat = provider.micAtIndexForDisplay(index);
final user = roomSeat?.user;
return _SeatRenderSnapshot(
isExitingCurrentVoiceRoomSession:
@ -398,8 +407,9 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
Widget build(BuildContext context) {
return Selector<RtcProvider, _SeatEmojiSnapshot>(
selector:
(context, provider) =>
_SeatEmojiSnapshot.fromMic(provider.roomWheatMap[widget.index]),
(context, provider) => _SeatEmojiSnapshot.fromMic(
provider.micAtIndexForDisplay(widget.index),
),
builder: (
BuildContext context,
_SeatEmojiSnapshot snapshot,

View File

@ -14,7 +14,10 @@ import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.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';
@ -138,6 +141,8 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
RoomEntranceHelper.clearQueue();
_clearLuckyGiftComboSessions();
_giftSeatFlightController.clear();
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
}
@ -355,6 +360,21 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
giftModel.giftCount = msg.number ?? 0;
giftModel.giftCountStepUnit = _resolveGiftCountStepUnit(msg);
unawaited(
warmImageResource(
giftModel.sendUserPic,
logicalWidth: 26.w,
logicalHeight: 26.w,
),
);
unawaited(
warmImageResource(
giftModel.giftPic,
logicalWidth: 34.w,
logicalHeight: 34.w,
),
);
Provider.of<GiftAnimationManager>(
context,
listen: false,
@ -398,6 +418,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
giftModel.rewardAmount = awardAmount;
giftModel.showLuckyRewardFrame = true;
giftModel.rewardAmountText = _formatLuckyRewardAmount(awardAmount);
unawaited(
warmImageResource(
giftModel.sendUserPic,
logicalWidth: 26.w,
logicalHeight: 26.w,
),
);
unawaited(
warmImageResource(
giftModel.giftPic,
logicalWidth: 34.w,
logicalHeight: 34.w,
),
);
Provider.of<GiftAnimationManager>(
context,
listen: false,
@ -443,11 +477,13 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
);
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
final previousTotal = session.totalCount;
session.totalCount += quantity;
final highestMilestone =
SocialChatGiftSystemManager.resolveHighestReachedComboMilestone(
session.totalCount,
SocialChatGiftSystemManager.resolveHighestCrossedComboEffectMilestone(
previousCount: previousTotal,
currentCount: session.totalCount,
);
if (highestMilestone != null &&
highestMilestone > session.highestPlayedMilestone &&
@ -527,12 +563,28 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
return math.max(msg.customAnimationCount ?? 1, 1);
}
num _resolveGiftCountStepUnit(Msg msg) {
final quantity = msg.number ?? 0;
final clickCount = math.max(msg.customAnimationCount ?? 1, 1);
if (quantity <= 0) {
return 1;
}
final stepUnit = quantity / clickCount;
if (stepUnit <= 0) {
return quantity;
}
return stepUnit;
}
void _enqueueTrackedSeatFlightAnimations({
required String sessionKey,
required String giftPhoto,
required String targetUserId,
required int animationCount,
}) {
unawaited(
warmImageResource(giftPhoto, logicalWidth: 96.w, logicalHeight: 96.w),
);
final normalizedAnimationCount = math.max(animationCount, 1);
final cappedAnimationCount = math.min(
normalizedAnimationCount,

View File

@ -14,6 +14,7 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
class MePage2 extends StatefulWidget {
const MePage2({super.key});
@ -153,9 +154,19 @@ class _MePage2State extends State<MePage2> {
),
),
SizedBox(height: 2.w),
Text(
'ID:${profile?.account ?? ''}',
style: TextStyle(
SCSpecialIdBadge(
idText: profile?.getID() ?? '',
showAnimated: profile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w600,

View File

@ -34,6 +34,7 @@ import '../../../app/constants/sc_screen.dart';
import '../../../shared/business_logic/models/res/sc_user_counter_res.dart';
import '../../../shared/business_logic/models/res/sc_user_identity_res.dart';
import '../../../shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../chat/chat_route.dart';
class PersonDetailPage extends StatefulWidget {
@ -398,11 +399,23 @@ class _PersonDetailPageState extends State<PersonDetailPage>
Row(
children: [
SizedBox(width: 25.w),
text(
"ID:${ref.userProfile?.account ?? ""}",
textColor: Colors.white,
fontWeight: FontWeight.w400,
fontSize: 16.sp,
SCSpecialIdBadge(
idText: ref.userProfile?.getID() ?? "",
showAnimated: ref.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
),
],
),

View File

@ -48,7 +48,7 @@ typedef RtcProvider = RealTimeCommunicationManager;
class RealTimeCommunicationManager extends ChangeNotifier {
static const Duration _micListPollingInterval = Duration(seconds: 2);
static const Duration _onlineUsersPollingInterval = Duration(seconds: 3);
static const Duration _selfMicSwitchGracePeriod = Duration(seconds: 4);
static const Duration _selfMicStateGracePeriod = Duration(seconds: 4);
static const Duration _giftTriggeredMicRefreshMinInterval = Duration(
milliseconds: 900,
);
@ -66,6 +66,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
num? _preferredSelfMicIndex;
num? _pendingSelfMicSourceIndex;
int? _pendingSelfMicSwitchGuardUntilMs;
num? _pendingSelfMicReleaseIndex;
int? _pendingSelfMicReleaseGuardUntilMs;
ClientRoleType? _lastAppliedClientRole;
bool? _lastAppliedLocalAudioMuted;
bool? _lastScheduledVoiceLiveOnMic;
@ -285,10 +287,11 @@ class RealTimeCommunicationManager extends ChangeNotifier {
required num sourceIndex,
required num targetIndex,
}) {
_clearSelfMicReleaseGuard();
_preferredSelfMicIndex = targetIndex;
_pendingSelfMicSourceIndex = sourceIndex;
_pendingSelfMicSwitchGuardUntilMs =
DateTime.now().add(_selfMicSwitchGracePeriod).millisecondsSinceEpoch;
DateTime.now().add(_selfMicStateGracePeriod).millisecondsSinceEpoch;
}
void _clearSelfMicSwitchGuard({bool clearPreferredIndex = false}) {
@ -299,6 +302,41 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
void _startSelfMicReleaseGuard({required num sourceIndex}) {
_pendingSelfMicReleaseIndex = sourceIndex;
_pendingSelfMicReleaseGuardUntilMs =
DateTime.now().add(_selfMicStateGracePeriod).millisecondsSinceEpoch;
}
void _clearSelfMicReleaseGuard() {
_pendingSelfMicReleaseIndex = null;
_pendingSelfMicReleaseGuardUntilMs = null;
}
bool _shouldSuppressCurrentUserOnSeat(num index, {MicRes? seat}) {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return false;
}
final releaseIndex = _pendingSelfMicReleaseIndex;
final releaseGuardUntilMs = _pendingSelfMicReleaseGuardUntilMs ?? 0;
if (releaseIndex != index ||
releaseGuardUntilMs <= DateTime.now().millisecondsSinceEpoch) {
return false;
}
final resolvedSeat = seat ?? roomWheatMap[index];
return (resolvedSeat?.user?.id ?? "").trim() == currentUserId;
}
MicRes? micAtIndexForDisplay(num index) {
final seat = roomWheatMap[index];
if (!_shouldSuppressCurrentUserOnSeat(index, seat: seat)) {
return seat;
}
return seat?.copyWith(clearUser: true);
}
Map<num, MicRes> _stabilizeSelfMicSnapshot(
Map<num, MicRes> nextMap, {
required Map<num, MicRes> previousMap,
@ -308,14 +346,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (currentUserId.isEmpty) {
return nextMap;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final targetIndex = _preferredSelfMicIndex;
final sourceIndex = _pendingSelfMicSourceIndex;
final guardUntilMs = _pendingSelfMicSwitchGuardUntilMs ?? 0;
final hasActiveGuard =
targetIndex != null &&
sourceIndex != null &&
guardUntilMs > DateTime.now().millisecondsSinceEpoch;
targetIndex != null && sourceIndex != null && guardUntilMs > nowMs;
final selfSeatIndices =
nextMap.entries
.where((entry) => entry.value.user?.id == currentUserId)
@ -330,63 +367,90 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (!hasActiveGuard) {
_clearSelfMicSwitchGuard();
return nextMap;
}
final resolvedTargetIndex = targetIndex;
final resolvedSourceIndex = sourceIndex;
} else {
final resolvedTargetIndex = targetIndex;
final resolvedSourceIndex = sourceIndex;
final selfOnlyOnSource =
selfSeatIndices.isEmpty ||
selfSeatIndices.every((seatIndex) => seatIndex == resolvedSourceIndex);
if (!selfOnlyOnSource) {
final selfOnlyOnSource =
selfSeatIndices.isEmpty ||
selfSeatIndices.every(
(seatIndex) => seatIndex == resolvedSourceIndex,
);
if (selfOnlyOnSource) {
final optimisticTargetSeat = previousMap[resolvedTargetIndex];
if (optimisticTargetSeat != null &&
optimisticTargetSeat.user?.id == currentUserId) {
final incomingTargetSeat = nextMap[resolvedTargetIndex];
if (incomingTargetSeat?.user == null ||
incomingTargetSeat?.user?.id == currentUserId) {
final stabilizedMap = Map<num, MicRes>.from(nextMap);
for (final seatIndex in selfSeatIndices) {
if (seatIndex == resolvedTargetIndex) {
continue;
}
final seat = stabilizedMap[seatIndex];
if (seat == null) {
continue;
}
stabilizedMap[seatIndex] = seat.copyWith(clearUser: true);
}
final baseSeat = incomingTargetSeat ?? optimisticTargetSeat;
stabilizedMap[resolvedTargetIndex] = baseSeat.copyWith(
user: optimisticTargetSeat.user,
micMute:
incomingTargetSeat?.micMute ?? optimisticTargetSeat.micMute,
micLock:
incomingTargetSeat?.micLock ?? optimisticTargetSeat.micLock,
roomToken:
incomingTargetSeat?.roomToken ??
optimisticTargetSeat.roomToken,
emojiPath:
(incomingTargetSeat?.emojiPath ?? "").isNotEmpty
? incomingTargetSeat?.emojiPath
: optimisticTargetSeat.emojiPath,
type:
(incomingTargetSeat?.type ?? "").isNotEmpty
? incomingTargetSeat?.type
: optimisticTargetSeat.type,
number:
(incomingTargetSeat?.number ?? "").isNotEmpty
? incomingTargetSeat?.number
: optimisticTargetSeat.number,
);
return stabilizedMap;
}
}
}
}
final releaseIndex = _pendingSelfMicReleaseIndex;
final releaseGuardUntilMs = _pendingSelfMicReleaseGuardUntilMs ?? 0;
final hasActiveReleaseGuard =
releaseIndex != null && releaseGuardUntilMs > nowMs;
if (!hasActiveReleaseGuard) {
_clearSelfMicReleaseGuard();
return nextMap;
}
final optimisticTargetSeat = previousMap[resolvedTargetIndex];
if (optimisticTargetSeat == null ||
optimisticTargetSeat.user?.id != currentUserId) {
if (selfSeatIndices.isEmpty) {
return nextMap;
}
final incomingTargetSeat = nextMap[resolvedTargetIndex];
if (incomingTargetSeat?.user != null &&
incomingTargetSeat?.user?.id != currentUserId) {
if (selfSeatIndices.any((seatIndex) => seatIndex != releaseIndex)) {
_clearSelfMicReleaseGuard();
return nextMap;
}
final releaseSeat = nextMap[releaseIndex];
if (releaseSeat?.user?.id != currentUserId) {
_clearSelfMicReleaseGuard();
return nextMap;
}
final stabilizedMap = Map<num, MicRes>.from(nextMap);
for (final seatIndex in selfSeatIndices) {
if (seatIndex == resolvedTargetIndex) {
continue;
}
final seat = stabilizedMap[seatIndex];
if (seat == null) {
continue;
}
stabilizedMap[seatIndex] = seat.copyWith(clearUser: true);
}
final baseSeat = incomingTargetSeat ?? optimisticTargetSeat;
stabilizedMap[resolvedTargetIndex] = baseSeat.copyWith(
user: optimisticTargetSeat.user,
micMute: incomingTargetSeat?.micMute ?? optimisticTargetSeat.micMute,
micLock: incomingTargetSeat?.micLock ?? optimisticTargetSeat.micLock,
roomToken:
incomingTargetSeat?.roomToken ?? optimisticTargetSeat.roomToken,
emojiPath:
(incomingTargetSeat?.emojiPath ?? "").isNotEmpty
? incomingTargetSeat?.emojiPath
: optimisticTargetSeat.emojiPath,
type:
(incomingTargetSeat?.type ?? "").isNotEmpty
? incomingTargetSeat?.type
: optimisticTargetSeat.type,
number:
(incomingTargetSeat?.number ?? "").isNotEmpty
? incomingTargetSeat?.number
: optimisticTargetSeat.number,
);
stabilizedMap[releaseIndex] = releaseSeat!.copyWith(clearUser: true);
return stabilizedMap;
}
@ -1208,6 +1272,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
needUpDataUserInfo = false;
SCRoomUtils.roomUsersMap.clear();
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_clearSelfMicReleaseGuard();
roomIsMute = false;
rtmProvider?.roomAllMsgList.clear();
rtmProvider?.roomChatMsgList.clear();
@ -1405,6 +1470,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final isSeatSwitching =
previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex;
if (myUser != null) {
_clearSelfMicReleaseGuard();
if (isSeatSwitching) {
_startSelfMicSwitchGuard(
sourceIndex: previousSelfSeatIndex,
@ -1454,20 +1520,30 @@ class RealTimeCommunicationManager extends ChangeNotifier {
index,
);
if (roomWheatMap[index]?.user?.id ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
final currentUserId =
AccountStorage().getCurrentUser()?.userProfile?.id ?? "";
final currentUserSeatIndex = userOnMaiInIndex(currentUserId);
if (roomWheatMap[index]?.user?.id == currentUserId) {
isMic = true;
engine?.muteLocalAudioStream(true);
}
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
if (currentUserSeatIndex > -1) {
_startSelfMicReleaseGuard(sourceIndex: currentUserSeatIndex);
} else {
_clearSelfMicReleaseGuard();
}
SCHeartbeatUtils.cancelAnchorTimer();
///
engine?.renewToken("");
engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
_clearUserFromSeats(AccountStorage().getCurrentUser()?.userProfile?.id);
_clearUserFromSeats(currentUserId);
notifyListeners();
_refreshMicListSilently();
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: const Duration(milliseconds: 350),
);
} catch (ex) {
SCTts.show('Failed to leave the microphone, $ex');
}
@ -1510,23 +1586,41 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///
bool isOnMai() {
return roomWheatMap.values
.map((userWheat) => userWheat.user?.id)
.toList()
.contains(AccountStorage().getCurrentUser()?.userProfile?.id);
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return false;
}
for (final entry in roomWheatMap.entries) {
if ((micAtIndexForDisplay(entry.key)?.user?.id ?? "").trim() ==
currentUserId) {
return true;
}
}
return false;
}
///
bool isOnMaiInIndex(num index) {
return roomWheatMap[index]?.user?.id ==
AccountStorage().getCurrentUser()?.userProfile?.id;
return (micAtIndexForDisplay(index)?.user?.id ?? "").trim() ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
}
///
num userOnMaiInIndex(String userId) {
final normalizedUserId = userId.trim();
if (normalizedUserId.isEmpty) {
return -1;
}
num index = -1;
roomWheatMap.forEach((k, value) {
if (value.user?.id == userId) {
final visibleSeat =
normalizedUserId ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "")
.trim()
? micAtIndexForDisplay(k)
: value;
if ((visibleSeat?.user?.id ?? "").trim() == normalizedUserId) {
index = k;
}
});

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
@ -53,6 +54,7 @@ import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push
import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/ui_kit/widgets/room/invite/invite_room_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
@ -76,6 +78,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
static const int _luckyGiftFloatMinMultiple = 5;
static const int _luckyGiftBurstMinMultiple = 10;
static const int _luckyGiftBurstMinAwardAmount = 5000;
static const int _luckyGiftBurstDisplayDurationMs = 2000;
BuildContext? context;
@ -1010,6 +1013,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
SCBroadCastLuckGiftPush broadCastRes,
) {
final rewardData = broadCastRes.data;
final resolvedGiftUrl = _resolveLuckyGiftGiftPhoto(rewardData);
return SCFloatingMessage(
type: 0,
userId: rewardData?.sendUserId,
@ -1018,7 +1022,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
userAvatarUrl: rewardData?.userAvatar,
userName: rewardData?.nickname,
toUserName: rewardData?.acceptNickname,
giftUrl: rewardData?.giftCover,
giftUrl: resolvedGiftUrl,
giftId: rewardData?.giftId,
number: rewardData?.giftQuantity,
coins: rewardData?.awardAmount,
multiple: rewardData?.multiple,
@ -1058,6 +1063,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (rewardData == null) {
return;
}
final resolvedGiftUrl = _resolveLuckyGiftGiftPhoto(rewardData);
final roomMsg = Msg(
groupId: '',
msg: '',
@ -1065,7 +1071,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
roomMsg.gift = SocialChatGiftRes(
id: rewardData.giftId,
giftPhoto: rewardData.giftCover,
giftPhoto: resolvedGiftUrl,
giftTab: 'LUCK',
);
roomMsg.number = 0;
@ -1089,6 +1095,11 @@ class RealTimeMessagingManager extends ChangeNotifier {
type: SCRoomMsgType.gameLuckyGift_5,
);
highlightMsg.awardAmount = rewardData.awardAmount;
highlightMsg.gift = SocialChatGiftRes(
id: rewardData.giftId,
giftPhoto: resolvedGiftUrl,
giftTab: 'LUCK',
);
highlightMsg.user = SocialChatUserProfile(
id: rewardData.sendUserId,
userNickname: rewardData.nickname,
@ -1108,6 +1119,46 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
}
String _resolveLuckyGiftGiftPhoto(Data? rewardData) {
final fallbackUrl = (rewardData?.giftCover ?? '').trim();
final giftId = (rewardData?.giftId ?? '').trim();
final currentContext = context;
if (giftId.isEmpty || currentContext == null || !currentContext.mounted) {
_giftFxLog(
'resolve lucky float gift photo skipped '
'giftId=$giftId '
'fallbackUrl=$fallbackUrl '
'reason=${giftId.isEmpty ? "empty_gift_id" : "invalid_context"}',
);
return fallbackUrl;
}
final appGeneralManager = Provider.of<SCAppGeneralManager>(
currentContext,
listen: false,
);
final giftById = appGeneralManager.getGiftById(giftId);
final giftByStandardId = appGeneralManager.getGiftByStandardId(giftId);
final localGift = giftById ?? giftByStandardId;
final localGiftPhoto = (localGift?.giftPhoto ?? '').trim();
_giftFxLog(
'resolve lucky float gift photo '
'giftId=$giftId '
'fallbackUrl=$fallbackUrl '
'giftById.id=${giftById?.id} '
'giftById.standardId=${giftById?.standardId} '
'giftById.photo=${giftById?.giftPhoto} '
'giftByStandardId.id=${giftByStandardId?.id} '
'giftByStandardId.standardId=${giftByStandardId?.standardId} '
'giftByStandardId.photo=${giftByStandardId?.giftPhoto} '
'resolvedUrl=${localGiftPhoto.isNotEmpty ? localGiftPhoto : fallbackUrl}',
);
if (localGiftPhoto.isNotEmpty) {
return localGiftPhoto;
}
unawaited(appGeneralManager.giftList());
return fallbackUrl;
}
bool isLogout = false;
logout() async {
@ -1765,12 +1816,15 @@ class RealTimeMessagingManager extends ChangeNotifier {
? null
: _luckyGiftPushEventKey(currentPlayingLuckGift!);
notifyListeners();
Future.delayed(Duration(milliseconds: 3000), () {
currentPlayingLuckGift = null;
_currentLuckGiftPushKey = null;
notifyListeners();
playLuckGiftBackCoins();
});
Future.delayed(
Duration(milliseconds: _luckyGiftBurstDisplayDurationMs),
() {
currentPlayingLuckGift = null;
_currentLuckGiftPushKey = null;
notifyListeners();
playLuckGiftBackCoins();
},
);
}
String _luckyGiftPushEventKey(SCBroadCastLuckGiftPush broadCastRes) {

View File

@ -27,6 +27,7 @@ class SCAppGeneralManager extends ChangeNotifier {
///
List<SocialChatGiftRes> giftResList = [];
final Map<String, SocialChatGiftRes> _giftByIdMap = {};
final Map<String, SocialChatGiftRes> _giftByStandardIdMap = {};
final Set<String> _warmedGiftCoverUrls = <String>{};
final Set<String> _warmingGiftCoverUrls = <String>{};
bool _isFetchingGiftList = false;
@ -169,6 +170,7 @@ class SCAppGeneralManager extends ChangeNotifier {
void _rebuildGiftTabs({required bool includeCustomized}) {
giftByTab.clear();
_giftByIdMap.clear();
_giftByStandardIdMap.clear();
for (var gift in giftResList) {
giftByTab[gift.giftTab];
var gmap = giftByTab[gift.giftTab];
@ -187,6 +189,10 @@ class SCAppGeneralManager extends ChangeNotifier {
}
giftByTab["ALL"] = gAllMap;
_giftByIdMap[gift.id!] = gift;
final standardId = (gift.standardId ?? "").trim();
if (standardId.isNotEmpty && standardId != "0") {
_giftByStandardIdMap[standardId] = gift;
}
}
if (includeCustomized) {
@ -277,6 +283,18 @@ class SCAppGeneralManager extends ChangeNotifier {
return _giftByIdMap[id];
}
SocialChatGiftRes? getGiftByStandardId(String standardId) {
return _giftByStandardIdMap[standardId];
}
SocialChatGiftRes? getGiftByIdOrStandardId(String id) {
final normalizedId = id.trim();
if (normalizedId.isEmpty) {
return null;
}
return _giftByIdMap[normalizedId] ?? _giftByStandardIdMap[normalizedId];
}
void downLoad(List<SocialChatGiftRes> giftResList) {
final scheduledPaths = <String>{};
var scheduledCount = 0;

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/cupertino.dart';
@ -6,6 +7,9 @@ import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart';
class GiftAnimationManager extends ChangeNotifier {
static const int _maxPendingAnimations = 24;
static const Duration _activeComboIdleDismissDelay = Duration(
milliseconds: 3200,
);
Queue<LGiftModel> pendingAnimationsQueue = Queue<LGiftModel>();
List<LGiftScrollingScreenAnimsBean> animationControllerList = [];
@ -16,6 +20,29 @@ class GiftAnimationManager extends ChangeNotifier {
bool get _controllersReady =>
animationControllerList.length >= giftMap.length;
GiftAnimationSlotSnapshot? slotSnapshotAt(int index) {
final gift = giftMap[index];
if (gift == null || animationControllerList.length <= index) {
return null;
}
final bean = animationControllerList[index];
return GiftAnimationSlotSnapshot(
index: index,
bean: bean,
sendUserName: gift.sendUserName,
sendToUserName: gift.sendToUserName,
sendUserPic: gift.sendUserPic,
giftPic: gift.giftPic,
giftName: gift.giftName,
giftCount: gift.giftCount,
rewardAmountText: gift.rewardAmountText,
rewardAmount: gift.rewardAmount,
showLuckyRewardFrame: gift.showLuckyRewardFrame,
labelId: gift.labelId,
giftCountStepUnit: gift.giftCountStepUnit,
);
}
void enqueueGiftAnimation(LGiftModel giftModel) {
if (_mergeIntoActiveAnimation(giftModel)) {
return;
@ -42,9 +69,7 @@ class GiftAnimationManager extends ChangeNotifier {
}
_mergeGiftModel(target: current, incoming: incoming);
notifyListeners();
if (animationControllerList.length > entry.key) {
animationControllerList[entry.key].controller.forward(from: 0.45);
}
_refreshSlotAnimation(entry.key, restartEntry: false);
return true;
}
return false;
@ -87,6 +112,9 @@ class GiftAnimationManager extends ChangeNotifier {
}
target.showLuckyRewardFrame =
target.showLuckyRewardFrame || incoming.showLuckyRewardFrame;
if (incoming.giftCountStepUnit > 0) {
target.giftCountStepUnit = incoming.giftCountStepUnit;
}
}
///
@ -101,14 +129,14 @@ class GiftAnimationManager extends ChangeNotifier {
giftMap[key] = playGift;
pendingAnimationsQueue.removeFirst();
notifyListeners();
animationControllerList[key].controller.forward(from: 0);
_refreshSlotAnimation(key, restartEntry: true);
break;
} else {
if (value.labelId == playGift.labelId) {
_mergeGiftModel(target: value, incoming: playGift);
pendingAnimationsQueue.removeFirst();
notifyListeners();
animationControllerList[key].controller.forward(from: 0.45);
_refreshSlotAnimation(key, restartEntry: false);
break;
}
}
@ -127,14 +155,125 @@ class GiftAnimationManager extends ChangeNotifier {
giftMap[2] = null;
giftMap[3] = null;
for (var element in animationControllerList) {
element.dismissTimer?.cancel();
element.controller.dispose();
element.countPulseController.dispose();
}
animationControllerList.clear();
}
void markAnimationAsFinished(int index) {
_cancelSlotDismissTimer(index);
if (giftMap[index] == null) {
return;
}
giftMap[index] = null;
notifyListeners();
proceedToNextAnimation();
}
void _refreshSlotAnimation(int index, {required bool restartEntry}) {
if (animationControllerList.length <= index) {
return;
}
final bean = animationControllerList[index];
_restartSlotDismissTimer(index);
bean.countPulseController.forward(from: 0);
if (restartEntry) {
bean.controller.forward(from: 0);
}
}
void _restartSlotDismissTimer(int index) {
if (animationControllerList.length <= index) {
return;
}
final bean = animationControllerList[index];
bean.dismissTimer?.cancel();
bean.dismissTimer = Timer(_activeComboIdleDismissDelay, () {
if (giftMap[index] == null) {
return;
}
markAnimationAsFinished(index);
});
}
void _cancelSlotDismissTimer(int index) {
if (animationControllerList.length <= index) {
return;
}
animationControllerList[index].dismissTimer?.cancel();
animationControllerList[index].dismissTimer = null;
}
}
@immutable
class GiftAnimationSlotSnapshot {
const GiftAnimationSlotSnapshot({
required this.index,
required this.bean,
required this.sendUserName,
required this.sendToUserName,
required this.sendUserPic,
required this.giftPic,
required this.giftName,
required this.giftCount,
required this.rewardAmountText,
required this.rewardAmount,
required this.showLuckyRewardFrame,
required this.labelId,
required this.giftCountStepUnit,
});
final int index;
final LGiftScrollingScreenAnimsBean bean;
final String sendUserName;
final String sendToUserName;
final String sendUserPic;
final String giftPic;
final String giftName;
final num giftCount;
final String rewardAmountText;
final num rewardAmount;
final bool showLuckyRewardFrame;
final String labelId;
final num giftCountStepUnit;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is GiftAnimationSlotSnapshot &&
other.index == index &&
identical(other.bean, bean) &&
other.sendUserName == sendUserName &&
other.sendToUserName == sendToUserName &&
other.sendUserPic == sendUserPic &&
other.giftPic == giftPic &&
other.giftName == giftName &&
other.giftCount == giftCount &&
other.rewardAmountText == rewardAmountText &&
other.rewardAmount == rewardAmount &&
other.showLuckyRewardFrame == showLuckyRewardFrame &&
other.labelId == labelId &&
other.giftCountStepUnit == giftCountStepUnit;
}
@override
int get hashCode => Object.hash(
index,
bean,
sendUserName,
sendToUserName,
sendUserPic,
giftPic,
giftName,
giftCount,
rewardAmountText,
rewardAmount,
showLuckyRewardFrame,
labelId,
giftCountStepUnit,
);
}

View File

@ -27,6 +27,8 @@ class SocialChatGiftSystemManager extends ChangeNotifier {
8000: "sc_images/room/anim/luck_gift/luck_gift_combo_count_8000.svga",
10000: "sc_images/room/anim/luck_gift/luck_gift_combo_count_10000.svga",
};
static final List<int> _luckGiftComboEffectMilestones =
(_luckGiftComboEffectAssets.keys.toList()..sort());
static const List<int> _luckGiftMilestones = <int>[
10,
20,
@ -211,7 +213,7 @@ class SocialChatGiftSystemManager extends ChangeNotifier {
}
void startGiftAnimation() {
final milestone = resolveHighestReachedComboMilestone(number);
final milestone = resolveHighestReachedComboEffectMilestone(number);
if (milestone == null || (isPlayed[milestone] ?? false)) {
return;
}
@ -261,19 +263,45 @@ class SocialChatGiftSystemManager extends ChangeNotifier {
return resolveHighestReachedComboMilestone(count);
}
static int? resolveHighestReachedComboEffectMilestone(num count) {
final normalizedCount = count.floor();
int? highest;
for (final milestone in _luckGiftComboEffectMilestones) {
if (milestone > normalizedCount) {
break;
}
highest = milestone;
}
return highest;
}
static int? resolveHighestCrossedComboEffectMilestone({
required num previousCount,
required num currentCount,
}) {
final previous = previousCount.floor();
final current = currentCount.floor();
if (current <= previous) {
return null;
}
int? highest;
for (final milestone in _luckGiftComboEffectMilestones) {
if (milestone <= previous) {
continue;
}
if (milestone > current) {
break;
}
highest = milestone;
}
return highest;
}
static String? resolveComboMilestoneEffectPath(num count) {
if (count % 1 != 0) {
return null;
}
final normalizedCount = count.toInt();
final comboEffectPath = _luckGiftComboEffectAssets[normalizedCount];
if (comboEffectPath != null) {
return comboEffectPath;
}
if (normalizedCount > 9999) {
return "sc_images/room/anim/luck_gift_count_5000_mor.mp4";
}
return "sc_images/room/anim/luck_gift_count_$normalizedCount.mp4";
return _luckGiftComboEffectAssets[count.toInt()];
}
static String? resolveLuckGiftComboEffectPath(num count) {

View File

@ -568,7 +568,7 @@ class SocialChatUserProfile {
///Id
String getID() {
String uid = account ?? "";
if (_ownSpecialId != null) {
if (hasSpecialId()) {
uid = _ownSpecialId?.account ?? "";
}
return uid;

View File

@ -7,6 +7,7 @@ class SCFloatingMessage {
String? userName; //
String? toUserName; //
String? giftUrl; //
String? giftId; // id
int? type;
int? rocketLevel;
num? coins;
@ -25,6 +26,7 @@ class SCFloatingMessage {
this.userName = '',
this.toUserName = '',
this.giftUrl = '',
this.giftId = '',
this.number = 0,
this.coins = 0,
this.priority = 10,
@ -42,6 +44,7 @@ class SCFloatingMessage {
userName = json['userName'];
toUserName = json['toUserName'];
giftUrl = json['giftUrl'];
giftId = json['giftId'];
coins = json['coins'];
number = json['number'];
priority = json['priority'];
@ -60,6 +63,7 @@ class SCFloatingMessage {
map['userName'] = userName;
map['toUserName'] = toUserName;
map['giftUrl'] = giftUrl;
map['giftId'] = giftId;
map['coins'] = coins;
map['number'] = number;
map['priority'] = priority;

View File

@ -1,8 +1,12 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/main.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/shared/tools/sc_entrance_vap_svga_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_game_screen_widget.dart';
@ -20,6 +24,7 @@ class OverlayManager {
);
bool _isPlaying = false;
OverlayEntry? _currentOverlayEntry;
SCFloatingMessage? _currentMessage;
bool _isProcessing = false;
bool _isDisposed = false;
@ -33,8 +38,17 @@ class OverlayManager {
void addMessage(SCFloatingMessage message) {
if (_isDisposed) return;
if (SCGlobalConfig.isFloatingAnimationInGlobal) {
_messageQueue.add(message);
_safeScheduleNext();
unawaited(_warmMessageImages(message));
SCRoomEffectScheduler().scheduleDeferredEffect(
debugLabel: 'floating_message_type_${message.type ?? -1}',
action: () {
if (_isDisposed) {
return;
}
_messageQueue.add(message);
_safeScheduleNext();
},
);
} else {
_messageQueue.clear();
_isPlaying = false;
@ -43,6 +57,30 @@ class OverlayManager {
}
}
Future<void> _warmMessageImages(SCFloatingMessage message) async {
unawaited(
warmImageResource(
message.userAvatarUrl ?? "",
logicalWidth: 80,
logicalHeight: 80,
),
);
unawaited(
warmImageResource(
message.toUserAvatarUrl ?? "",
logicalWidth: 80,
logicalHeight: 80,
),
);
unawaited(
warmImageResource(
message.giftUrl ?? "",
logicalWidth: 96,
logicalHeight: 96,
),
);
}
void _safeScheduleNext() {
if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return;
_isProcessing = true;
@ -65,17 +103,10 @@ class OverlayManager {
if (_messageQueue.isEmpty) return;
final messageToProcess = _messageQueue.first;
if (messageToProcess?.type == 1) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
);
if (rtcProvider.currenRoom == null) {
//
_messageQueue.removeFirst();
_safeScheduleNext();
return;
}
if (!_shouldDisplayMessage(context, messageToProcess)) {
_messageQueue.removeFirst();
_safeScheduleNext();
return;
}
//
@ -90,9 +121,11 @@ class OverlayManager {
void _playMessage(SCFloatingMessage message) {
_isPlaying = true;
_currentMessage = message;
final context = navigatorKey.currentState?.context;
if (context == null || !context.mounted) {
_isPlaying = false;
_currentMessage = null;
_safeScheduleNext();
return;
}
@ -103,7 +136,7 @@ class OverlayManager {
alignment: AlignmentDirectional.topStart,
child: Transform.translate(
offset: Offset(0, 70.w),
child: _buildScreenWidget(message),
child: RepaintBoundary(child: _buildScreenWidget(message)),
),
),
);
@ -122,10 +155,12 @@ class OverlayManager {
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
_isPlaying = false;
_currentMessage = null;
_safeScheduleNext();
} catch (e) {
debugPrint('清理悬浮消息出错: $e');
_isPlaying = false;
_currentMessage = null;
_safeScheduleNext();
}
}
@ -174,14 +209,16 @@ class OverlayManager {
_isDisposed = true;
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
_currentMessage = null;
_messageQueue.clear();
_isPlaying = false;
_isProcessing = false;
}
void removeRoom() {
// PriorityQueue
_removeActiveRoomMessage();
_removeMessagesByType(1);
_removeMessagesByType(0);
}
//
@ -210,4 +247,41 @@ class OverlayManager {
int get queueLength => _messageQueue.length;
bool get isPlaying => _isPlaying;
bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) {
if (message == null) {
return false;
}
if (message.type != 0 && message.type != 1) {
return true;
}
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
);
if (!rtcProvider.shouldShowRoomVisualEffects) {
return false;
}
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
final messageRoomId = (message.roomId ?? "").trim();
if (currentRoomId.isEmpty || messageRoomId.isEmpty) {
return false;
}
return currentRoomId == messageRoomId;
}
void _removeActiveRoomMessage() {
final activeMessage = _currentMessage;
if (activeMessage == null ||
(activeMessage.type != 0 && activeMessage.type != 1)) {
return;
}
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
_currentMessage = null;
_isPlaying = false;
_safeScheduleNext();
}
}

View File

@ -130,6 +130,7 @@ class BaseNetworkClient {
String path, {
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
bool allowNullBody = false,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -140,6 +141,7 @@ class BaseNetworkClient {
method: 'GET',
queryParams: queryParams,
extra: extra,
allowNullBody: allowNullBody,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -153,6 +155,7 @@ class BaseNetworkClient {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
bool allowNullBody = false,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -164,6 +167,7 @@ class BaseNetworkClient {
data: data,
queryParams: queryParams,
extra: extra,
allowNullBody: allowNullBody,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -177,6 +181,7 @@ class BaseNetworkClient {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
bool allowNullBody = false,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -188,6 +193,7 @@ class BaseNetworkClient {
data: data,
queryParams: queryParams,
extra: extra,
allowNullBody: allowNullBody,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -201,6 +207,7 @@ class BaseNetworkClient {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
bool allowNullBody = false,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -212,6 +219,7 @@ class BaseNetworkClient {
data: data,
queryParams: queryParams,
extra: extra,
allowNullBody: allowNullBody,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -226,6 +234,7 @@ class BaseNetworkClient {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
bool allowNullBody = false,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
@ -251,6 +260,8 @@ class BaseNetworkClient {
if (baseResponse.success) {
if (baseResponse.body != null) {
return baseResponse.body!;
} else if (allowNullBody && null is T) {
return null as T;
} else {
if (T == bool) return false as T;
if (T == int) return 0 as T;

View File

@ -213,6 +213,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
final result = await http.post(
"11335ecd55d99c9100f5cd070e5b73f1574aff257ce7e668d08f4caccd1c6232",
data: {"roomId": roomId, "mickIndex": mickIndex},
allowNullBody: true,
fromJson: (json) => null,
);
return result;
@ -224,6 +225,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
final result = await http.post(
"70ffb9681e0103463b1faf883935e55d",
data: {"roomId": roomId, "mickIndex": mickIndex, "lock": lock},
allowNullBody: true,
fromJson: (json) => null,
);
return result;
@ -405,6 +407,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
final result = await http.post(
"68900c787a89c1092b21ee1a610ef323380d4f62784dacdb4e3d4e1f60770a39",
data: {"roomId": roomId, "userId": userId},
allowNullBody: true,
fromJson: (json) => null,
);
return result;

View File

@ -348,6 +348,7 @@ class SCAccountRepository implements SocialChatUserRepository {
final result = await http.post(
"745ce65f1d68f106702ad3c636aca0e0ac57f82127b3ac414551924946593db7",
data: {"pwd": pwd},
allowNullBody: true,
fromJson: (json) => null,
);
return result;

View File

@ -6,6 +6,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_svga/flutter_svga.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:tancent_vap/utils/constant.dart';
import 'package:tancent_vap/widgets/vap_view.dart';
@ -267,6 +268,9 @@ class SCGiftVapSvgaManager {
if (_dis) {
return;
}
SCRoomEffectScheduler().completeHighCostTask(
debugLabel: _currentTask?.path,
);
_play = false;
_currentTask = null;
_scheduleNextTask(delay: delay);
@ -316,6 +320,9 @@ class SCGiftVapSvgaManager {
if (status.isCompleted) {
_log('svga completed path=${_currentTask?.path}');
_rsc?.reset();
SCRoomEffectScheduler().completeHighCostTask(
debugLabel: _currentTask?.path,
);
_play = false;
_currentTask = null;
_pn();
@ -363,8 +370,13 @@ class SCGiftVapSvgaManager {
return;
}
_tq.add(task);
SCRoomEffectScheduler().registerHighCostTaskQueued(debugLabel: path);
unawaited(preload(path));
while (_tq.length > _maxPendingTaskCount) {
final removedTask = _tq.removeLast();
SCRoomEffectScheduler().completeHighCostTask(
debugLabel: removedTask.path,
);
_log(
'trim queued task path=${removedTask.path} '
'priority=${removedTask.priority} queue=${_tq.length}',
@ -547,6 +559,7 @@ class SCGiftVapSvgaManager {
_rsc?.stop();
_rsc?.reset();
_rsc?.videoItem = null;
SCRoomEffectScheduler().clearHighCostTasks(reason: 'stop_playback');
}
//

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/painting.dart';
@ -37,26 +38,56 @@ Map<String, String>? buildNetworkImageHeaders(String url) {
return headers;
}
ImageProvider<Object> buildCachedImageProvider(String url) {
int? _resolveImageProviderCacheDimension(double? logicalSize) {
if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) {
return null;
}
final devicePixelRatio =
PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 2.0;
final maxDimension = SCGlobalConfig.isLowPerformanceDevice ? 1280 : 1920;
final pixelSize = (logicalSize * devicePixelRatio).round();
return pixelSize.clamp(1, maxDimension).toInt();
}
ImageProvider<Object> buildCachedImageProvider(
String url, {
double? logicalWidth,
double? logicalHeight,
}) {
late final ImageProvider<Object> provider;
if (url.startsWith("http")) {
return ExtendedNetworkImageProvider(
provider = ExtendedNetworkImageProvider(
url,
headers: buildNetworkImageHeaders(url),
cache: true,
cacheMaxAge: const Duration(days: 7),
);
} else if (url.startsWith('/')) {
provider = FileImage(File(url));
} else {
provider = AssetImage(url);
}
return FileImage(File(url));
return ResizeImage.resizeIfNeeded(
_resolveImageProviderCacheDimension(logicalWidth),
_resolveImageProviderCacheDimension(logicalHeight),
provider,
);
}
Future<bool> warmNetworkImage(
String url, {
Future<bool> warmImageResource(
String resource, {
double? logicalWidth,
double? logicalHeight,
Duration timeout = const Duration(seconds: 6),
}) async {
if (url.isEmpty) {
if (resource.isEmpty) {
return false;
}
final provider = buildCachedImageProvider(url);
final provider = buildCachedImageProvider(
resource,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
);
final stream = provider.resolve(ImageConfiguration.empty);
final completer = Completer<bool>();
late final ImageStreamListener listener;
@ -83,3 +114,17 @@ Future<bool> warmNetworkImage(
},
);
}
Future<bool> warmNetworkImage(
String url, {
double? logicalWidth,
double? logicalHeight,
Duration timeout = const Duration(seconds: 6),
}) {
return warmImageResource(
url,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
timeout: timeout,
);
}

View File

@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
class SCRoomEffectScheduler {
static const Duration _deferredDrainInterval = Duration(milliseconds: 60);
static final SCRoomEffectScheduler _instance =
SCRoomEffectScheduler._internal();
factory SCRoomEffectScheduler() => _instance;
SCRoomEffectScheduler._internal();
final Queue<_DeferredRoomEffectTask> _deferredTasks =
Queue<_DeferredRoomEffectTask>();
Timer? _deferredDrainTimer;
int _pendingHighCostTaskCount = 0;
int _nextTaskId = 0;
bool get hasActiveHighCostEffects => _pendingHighCostTaskCount > 0;
void registerHighCostTaskQueued({String? debugLabel}) {
_pendingHighCostTaskCount += 1;
_cancelDeferredDrain();
_log(
'register_high_cost task=${debugLabel ?? "unknown"} '
'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}',
);
}
void completeHighCostTask({String? debugLabel}) {
if (_pendingHighCostTaskCount > 0) {
_pendingHighCostTaskCount -= 1;
}
_log(
'complete_high_cost task=${debugLabel ?? "unknown"} '
'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}',
);
_scheduleDeferredDrainIfNeeded();
}
void clearHighCostTasks({String reason = 'unknown'}) {
if (_pendingHighCostTaskCount == 0) {
_scheduleDeferredDrainIfNeeded();
return;
}
_pendingHighCostTaskCount = 0;
_log(
'clear_high_cost reason=$reason '
'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}',
);
_scheduleDeferredDrainIfNeeded();
}
void scheduleDeferredEffect({
required String debugLabel,
required VoidCallback action,
}) {
if (!hasActiveHighCostEffects) {
action();
return;
}
final task = _DeferredRoomEffectTask(
id: _nextTaskId++,
debugLabel: debugLabel,
action: action,
);
_deferredTasks.add(task);
_log(
'defer task=${task.debugLabel} id=${task.id} '
'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}',
);
}
void clearDeferredTasks({String reason = 'unknown'}) {
if (_deferredTasks.isEmpty) {
_cancelDeferredDrain();
return;
}
_deferredTasks.clear();
_cancelDeferredDrain();
_log('clear_deferred reason=$reason');
}
void _scheduleDeferredDrainIfNeeded() {
if (hasActiveHighCostEffects ||
_deferredTasks.isEmpty ||
_deferredDrainTimer != null) {
return;
}
_deferredDrainTimer = Timer(_deferredDrainInterval, _drainNextDeferredTask);
}
void _drainNextDeferredTask() {
_deferredDrainTimer = null;
if (hasActiveHighCostEffects || _deferredTasks.isEmpty) {
return;
}
final task = _deferredTasks.removeFirst();
_log(
'drain_deferred task=${task.debugLabel} id=${task.id} '
'remaining=${_deferredTasks.length}',
);
try {
task.action();
} catch (error, stackTrace) {
debugPrint(
'[RoomEffectScheduler] deferred task failed: $error\n$stackTrace',
);
}
_scheduleDeferredDrainIfNeeded();
}
void _cancelDeferredDrain() {
_deferredDrainTimer?.cancel();
_deferredDrainTimer = null;
}
void _log(String message) {
debugPrint('[RoomEffectScheduler] $message');
}
}
class _DeferredRoomEffectTask {
const _DeferredRoomEffectTask({
required this.id,
required this.debugLabel,
required this.action,
});
final int id;
final String debugLabel;
final VoidCallback action;
}

View File

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class SCSpecialIdAssets {
static const String roomId = 'sc_images/general/room_id.svga';
static const String roomOwnerId = 'sc_images/general/room_id_custom.svga';
static const String userId = 'sc_images/general/user_id.svga';
static const String userIdLarge = 'sc_images/general/user_id_4.svga';
}
class SCSpecialIdBadge extends StatelessWidget {
const SCSpecialIdBadge({
super.key,
required this.idText,
this.showAnimated = false,
this.assetPath,
this.animationWidth = 120,
this.animationHeight = 28,
this.textPadding = EdgeInsets.zero,
this.animationTextStyle,
this.normalTextStyle,
this.normalPrefix = 'ID:',
this.showCopyIcon = false,
this.copyIconAssetPath = 'sc_images/room/sc_icon_user_card_copy_id.png',
this.copyIconSize = 12,
this.copyIconSpacing = 5,
this.copyIconColor,
this.loop = true,
this.active = true,
this.animationFit = BoxFit.fill,
});
final String idText;
final bool showAnimated;
final String? assetPath;
final double animationWidth;
final double animationHeight;
final EdgeInsetsGeometry textPadding;
final TextStyle? animationTextStyle;
final TextStyle? normalTextStyle;
final String normalPrefix;
final bool showCopyIcon;
final String copyIconAssetPath;
final double copyIconSize;
final double copyIconSpacing;
final Color? copyIconColor;
final bool loop;
final bool active;
final BoxFit animationFit;
@override
Widget build(BuildContext context) {
final normalizedId = idText.trim();
final shouldAnimate =
showAnimated &&
(assetPath?.isNotEmpty ?? false) &&
normalizedId.isNotEmpty;
final children = <Widget>[
shouldAnimate
? _buildAnimatedBadge(normalizedId)
: _buildPlainBadge(normalizedId),
];
if (showCopyIcon) {
children.add(SizedBox(width: copyIconSpacing));
children.add(
Image.asset(
copyIconAssetPath,
width: copyIconSize,
height: copyIconSize,
color: copyIconColor,
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
textDirection: TextDirection.ltr,
children: children,
);
}
Widget _buildAnimatedBadge(String normalizedId) {
return SizedBox(
width: animationWidth,
height: animationHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: SCSvgaAssetWidget(
assetPath: assetPath!,
width: animationWidth,
height: animationHeight,
active: active,
loop: loop,
fit: animationFit,
fallback: const SizedBox.shrink(),
),
),
Positioned.fill(
child: Padding(
padding: textPadding,
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
normalizedId,
maxLines: 1,
overflow: TextOverflow.visible,
style:
animationTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
);
}
Widget _buildPlainBadge(String normalizedId) {
return Text(
'$normalPrefix$normalizedId',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
normalTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
@ -19,62 +21,145 @@ class LGiftAnimalPage extends StatefulWidget {
class _GiftAnimalPageState extends State<LGiftAnimalPage>
with TickerProviderStateMixin {
static const String _luckyGiftRewardFrameAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga";
late final GiftAnimationManager _giftAnimationManager;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 332.w,
child: Consumer<GiftAnimationManager>(
builder: (context, ref, child) {
return Stack(
clipBehavior: Clip.none,
children: List.generate(
4,
(index) => _buildGiftTickerItem(context, ref, index),
),
);
},
child: Stack(
clipBehavior: Clip.none,
children: List.generate(4, (index) => _GiftTickerSlot(index: index)),
),
);
}
Widget _buildGiftTickerItem(
BuildContext context,
GiftAnimationManager ref,
int index,
) {
final gift = ref.giftMap[index];
if (gift == null || ref.animationControllerList.length <= index) {
return const SizedBox.shrink();
@override
void dispose() {
_giftAnimationManager.cleanupAnimationResources();
super.dispose();
}
@override
void initState() {
super.initState();
_giftAnimationManager = Provider.of<GiftAnimationManager>(
context,
listen: false,
);
initAnimal();
}
void initAnimal() {
List<LGiftScrollingScreenAnimsBean> beans = [];
double top = 60;
for (int i = 0; i < 4; i++) {
var bean = LGiftScrollingScreenAnimsBean();
var controller = AnimationController(
value: 0,
duration: const Duration(milliseconds: 360),
vsync: this,
);
bean.controller = controller;
bean.countPulseController = AnimationController(
value: 1,
duration: const Duration(milliseconds: 280),
vsync: this,
);
bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top);
bean.verticalAnimation = EdgeInsetsTween(
begin: EdgeInsets.only(top: top),
end: EdgeInsets.only(top: 0),
).animate(
CurvedAnimation(parent: controller, curve: Curves.easeOutCubic),
);
bean.sizeAnimation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(
begin: 1,
end: 1.18,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: 65,
),
TweenSequenceItem(
tween: Tween<double>(
begin: 1.18,
end: 1,
).chain(CurveTween(curve: Curves.easeInCubic)),
weight: 35,
),
]).animate(
CurvedAnimation(
parent: bean.countPulseController,
curve: Curves.linear,
),
);
beans.add(bean);
top = top + 70;
}
final bean = ref.animationControllerList[index];
return AnimatedBuilder(
animation: bean.controller,
builder: (context, child) {
final showLuckyRewardFrame = gift.showLuckyRewardFrame;
final tickerMargin =
showLuckyRewardFrame
? bean.luckyGiftPinnedMargin
: bean.verticalAnimation.value;
return Container(
margin: tickerMargin,
width:
ScreenUtil().screenWidth * (showLuckyRewardFrame ? 0.96 : 0.78),
child: _buildGiftTickerCard(context, gift, bean.sizeAnimation.value),
_giftAnimationManager.attachAnimationControllers(beans);
}
String getBg(String type) {
return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png";
}
}
class _GiftTickerSlot extends StatelessWidget {
const _GiftTickerSlot({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Selector<GiftAnimationManager, GiftAnimationSlotSnapshot?>(
selector: (context, manager) => manager.slotSnapshotAt(index),
builder: (context, snapshot, child) {
if (snapshot == null) {
return const SizedBox.shrink();
}
return RepaintBoundary(
child: AnimatedBuilder(
animation: Listenable.merge([
snapshot.bean.controller,
snapshot.bean.countPulseController,
]),
builder: (context, child) {
final showLuckyRewardFrame = snapshot.showLuckyRewardFrame;
final tickerMargin =
showLuckyRewardFrame
? snapshot.bean.luckyGiftPinnedMargin
: snapshot.bean.verticalAnimation.value;
return Container(
margin: tickerMargin,
width:
ScreenUtil().screenWidth *
(showLuckyRewardFrame ? 0.96 : 0.78),
child: _GiftTickerCard(
snapshot: snapshot,
animatedSize: 22.sp * snapshot.bean.sizeAnimation.value,
),
);
},
),
);
},
);
}
}
Widget _buildGiftTickerCard(
BuildContext context,
LGiftModel gift,
double animatedSize,
) {
final showLuckyRewardFrame = gift.showLuckyRewardFrame;
class _GiftTickerCard extends StatelessWidget {
const _GiftTickerCard({required this.snapshot, required this.animatedSize});
static const String _luckyGiftRewardFrameAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga";
final GiftAnimationSlotSnapshot snapshot;
final double animatedSize;
@override
Widget build(BuildContext context) {
final showLuckyRewardFrame = snapshot.showLuckyRewardFrame;
return SizedBox(
height: 52.w,
child: Stack(
@ -91,16 +176,18 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
top: 3.w,
bottom: 3.w,
),
decoration: BoxDecoration(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(getBg("")),
image: AssetImage(
"sc_images/room/sc_icon_room_gift_left_no_vip_bg.png",
),
fit: BoxFit.fill,
),
),
child: Row(
children: <Widget>[
netImage(
url: gift.sendUserPic,
url: snapshot.sendUserPic,
shape: BoxShape.circle,
width: 26.w,
height: 26.w,
@ -114,11 +201,11 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
children: [
socialchatNickNameText(
maxWidth: 88.w,
gift.sendUserName,
snapshot.sendUserName,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
type: "",
needScroll: gift.sendUserName.characters.length > 8,
needScroll: snapshot.sendUserName.characters.length > 8,
),
SizedBox(height: 1.w),
Row(
@ -133,7 +220,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
),
Flexible(
child: Text(
gift.sendToUserName,
snapshot.sendToUserName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@ -165,25 +252,25 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
netImage(
url: gift.giftPic,
url: snapshot.giftPic,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(4.w),
width: 34.w,
height: 34.w,
),
if (gift.giftCount > 0) ...[
if (snapshot.giftCount > 0) ...[
SizedBox(width: 8.w),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: _buildGiftCountLabel(gift, animatedSize),
child: _buildGiftCountLabel(snapshot.giftCount),
),
),
],
if (showLuckyRewardFrame) ...[
SizedBox(width: 6.w),
_buildLuckyGiftRewardFrame(gift),
_buildLuckyGiftRewardFrame(),
],
],
),
@ -194,9 +281,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
);
}
Widget _buildGiftCountLabel(LGiftModel gift, double animatedSize) {
final xFontSize = animatedSize;
final countFontSize = animatedSize;
Widget _buildGiftCountLabel(num giftCount) {
return Directionality(
textDirection: TextDirection.ltr,
child: Row(
@ -206,7 +291,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
Text(
"x",
style: TextStyle(
fontSize: xFontSize,
fontSize: animatedSize,
fontStyle: FontStyle.italic,
color: const Color(0xFFFFD400),
fontWeight: FontWeight.bold,
@ -214,10 +299,11 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
),
),
SizedBox(width: 2.w),
Text(
_giftCountText(gift.giftCount),
_AnimatedGiftCountText(
targetCount: giftCount,
stepUnit: snapshot.giftCountStepUnit,
style: TextStyle(
fontSize: countFontSize,
fontSize: animatedSize,
fontStyle: FontStyle.italic,
color: const Color(0xFFFFD400),
fontWeight: FontWeight.bold,
@ -229,21 +315,23 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
);
}
Widget _buildLuckyGiftRewardFrame(LGiftModel gift) {
Widget _buildLuckyGiftRewardFrame() {
return SizedBox(
width: 108.w,
height: 52.w,
child: Stack(
alignment: Alignment.center,
children: [
SCSvgaAssetWidget(
assetPath: _luckyGiftRewardFrameAssetPath,
width: 108.w,
height: 52.w,
fit: BoxFit.cover,
loop: true,
allowDrawingOverflow: true,
fallback: _buildLuckyGiftRewardFrameFallback(),
RepaintBoundary(
child: SCSvgaAssetWidget(
assetPath: _luckyGiftRewardFrameAssetPath,
width: 108.w,
height: 52.w,
fit: BoxFit.cover,
loop: true,
allowDrawingOverflow: true,
fallback: const _LuckyGiftRewardFrameFallback(),
),
),
Padding(
padding: EdgeInsetsDirectional.only(
@ -254,7 +342,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"+${_giftRewardAmountText(gift)}",
"+${_giftRewardAmountText()}",
maxLines: 1,
style: TextStyle(
fontSize: 16.sp,
@ -278,7 +366,26 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
);
}
Widget _buildLuckyGiftRewardFrameFallback() {
String _giftRewardAmountText() {
final rewardAmount = snapshot.rewardAmount;
if (rewardAmount > 0) {
if (rewardAmount > 9999) {
return "${(rewardAmount / 1000).toStringAsFixed(0)}k";
}
if (rewardAmount % 1 == 0) {
return rewardAmount.toInt().toString();
}
return rewardAmount.toString();
}
return snapshot.rewardAmountText;
}
}
class _LuckyGiftRewardFrameFallback extends StatelessWidget {
const _LuckyGiftRewardFrameFallback();
@override
Widget build(BuildContext context) {
return Container(
width: 108.w,
height: 52.w,
@ -300,118 +407,154 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
),
);
}
}
String _giftCountText(num count) {
return count % 1 == 0 ? count.toInt().toString() : count.toString();
}
class _AnimatedGiftCountText extends StatefulWidget {
const _AnimatedGiftCountText({
required this.targetCount,
required this.stepUnit,
required this.style,
});
String _giftRewardAmountText(LGiftModel gift) {
final rewardAmount = gift.rewardAmount;
if (rewardAmount > 0) {
if (rewardAmount > 9999) {
return "${(rewardAmount / 1000).toStringAsFixed(0)}k";
}
if (rewardAmount % 1 == 0) {
return rewardAmount.toInt().toString();
}
return rewardAmount.toString();
}
return gift.rewardAmountText;
}
final num targetCount;
final num stepUnit;
final TextStyle style;
@override
void dispose() {
_giftAnimationManager.cleanupAnimationResources();
super.dispose();
}
State<_AnimatedGiftCountText> createState() => _AnimatedGiftCountTextState();
}
class _AnimatedGiftCountTextState extends State<_AnimatedGiftCountText> {
Timer? _stepTimer;
late double _displayValue;
@override
void initState() {
super.initState();
_giftAnimationManager = Provider.of<GiftAnimationManager>(
context,
listen: false,
);
initAnimal();
_displayValue = _resolveInitialDisplayValue();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_startSteppedAnimation(widget.targetCount.toDouble());
});
}
void initAnimal() {
List<LGiftScrollingScreenAnimsBean> beans = [];
double top = 60;
for (int i = 0; i < 4; i++) {
var bean = LGiftScrollingScreenAnimsBean();
var controller = AnimationController(
value: 0,
duration: const Duration(milliseconds: 5000),
vsync: this,
);
bean.controller = controller;
bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top);
// bean.transverseAnimation = Tween<Offset>(
// begin: Offset(ScreenUtil().screenWidth, 0),
// end: Offset(0, 0),
// ).animate(
// CurvedAnimation(
// parent: controller,
// curve: Interval(0.0, 0.45, curve: Curves.ease),
// ),
// );
bean.verticalAnimation = EdgeInsetsTween(
begin: EdgeInsets.only(top: top),
end: EdgeInsets.only(top: 0),
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.2, 1, curve: Curves.easeOut),
),
);
bean.sizeAnimation = Tween<double>(begin: 0, end: 22).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.45, 0.55, curve: Curves.ease),
),
);
beans.add(bean);
top = top + 70;
@override
void didUpdateWidget(covariant _AnimatedGiftCountText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.targetCount == widget.targetCount &&
oldWidget.stepUnit == widget.stepUnit) {
return;
}
beans[0].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
_giftAnimationManager.markAnimationAsFinished(0);
}
});
beans[1].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
_giftAnimationManager.markAnimationAsFinished(1);
}
});
beans[2].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
_giftAnimationManager.markAnimationAsFinished(2);
}
});
beans[3].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
_giftAnimationManager.markAnimationAsFinished(3);
}
});
_giftAnimationManager.attachAnimationControllers(beans);
_startSteppedAnimation(widget.targetCount.toDouble());
}
String getBg(String type) {
return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png";
@override
void dispose() {
_stepTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(_formatGiftCount(_resolvedDisplayCount()), style: widget.style);
}
double _resolveInitialDisplayValue() {
final targetValue = widget.targetCount.toDouble();
final stepUnit = _resolveStepUnit(targetValue.abs());
if (targetValue <= 0 || stepUnit <= 0) {
return targetValue;
}
return targetValue <= stepUnit ? targetValue : stepUnit;
}
void _startSteppedAnimation(double nextValue) {
_stepTimer?.cancel();
final delta = nextValue - _displayValue;
if (delta.abs() < 0.0001) {
return;
}
if (delta < 0) {
if (!mounted) {
return;
}
setState(() {
_displayValue = nextValue;
});
return;
}
final stepUnit = _resolveStepUnit(delta.abs());
if (stepUnit <= 0) {
if (!mounted) {
return;
}
setState(() {
_displayValue = nextValue;
});
return;
}
var tickCount = (delta.abs() / stepUnit).ceil();
if (tickCount <= 1) {
if (!mounted) {
return;
}
setState(() {
_displayValue = nextValue;
});
return;
}
tickCount = tickCount.clamp(2, 40);
final increment = delta / tickCount;
final intervalMs = ((700 / tickCount).round()).clamp(28, 80);
var completedTicks = 0;
_stepTimer = Timer.periodic(Duration(milliseconds: intervalMs), (timer) {
if (!mounted) {
timer.cancel();
return;
}
completedTicks += 1;
final reachedEnd = completedTicks >= tickCount;
setState(() {
_displayValue = reachedEnd ? nextValue : (_displayValue + increment);
});
if (reachedEnd) {
timer.cancel();
_stepTimer = null;
}
});
}
double _resolveStepUnit(double delta) {
final candidate = widget.stepUnit.abs().toDouble();
if (candidate <= 0) {
return delta;
}
return candidate.clamp(0.0001, delta);
}
num _resolvedDisplayCount() {
if (widget.targetCount % 1 == 0) {
return _displayValue.round();
}
return double.parse(_displayValue.toStringAsFixed(1));
}
}
String _formatGiftCount(num count) {
return count % 1 == 0 ? count.toInt().toString() : count.toString();
}
class LGiftScrollingScreenAnimsBean {
// late Animation<Offset> transverseAnimation;
late Animation<EdgeInsets> verticalAnimation;
late EdgeInsets luckyGiftPinnedMargin;
late AnimationController controller;
late AnimationController countPulseController;
late Animation<double> sizeAnimation;
Timer? dismissTimer;
}
class LGiftModel {
@ -444,4 +587,7 @@ class LGiftModel {
//id
String labelId = "";
// 1 1 25 25
num giftCountStepUnit = 1;
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@ -7,6 +9,8 @@ import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:provider/provider.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
class RoomAnimationQueueScreen extends StatefulWidget {
@ -36,7 +40,22 @@ class _RoomAnimationQueueScreenState extends State<RoomAnimationQueueScreen> {
if (!_effectsEnabled) {
return;
}
_addToQueue(msg);
unawaited(
warmImageResource(
msg.user?.userAvatar ?? "",
logicalWidth: 28.w,
logicalHeight: 28.w,
),
);
SCRoomEffectScheduler().scheduleDeferredEffect(
debugLabel: 'room_entrance_join',
action: () {
if (!_effectsEnabled || !mounted) {
return;
}
_addToQueue(msg);
},
);
});
}
@ -44,6 +63,7 @@ class _RoomAnimationQueueScreenState extends State<RoomAnimationQueueScreen> {
if (!_effectsEnabled || !mounted) {
return;
}
bool shouldStartProcessing = false;
setState(() {
final taskId = DateTime.now().millisecondsSinceEpoch;
final animationKey = GlobalKey<_RoomEntranceAnimationState>();
@ -72,10 +92,13 @@ class _RoomAnimationQueueScreenState extends State<RoomAnimationQueueScreen> {
);
_animationQueue.add(task);
if (!_isQueueProcessing && _animationQueue.isNotEmpty) {
_isQueueProcessing = true;
shouldStartProcessing = true;
}
});
if (!_isQueueProcessing && _animationQueue.isNotEmpty) {
setState(() => _isQueueProcessing = true);
if (shouldStartProcessing) {
_startNextAnimation();
}
}
@ -267,53 +290,56 @@ class _RoomEntranceAnimationState extends State<RoomEntranceAnimation>
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _offsetAnimation,
child: Container(
width: ScreenUtil().screenWidth * 0.8,
height: 37.w,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(getEntranceBg("")),
fit: BoxFit.fill,
return RepaintBoundary(
child: SlideTransition(
position: _offsetAnimation,
child: Container(
width: ScreenUtil().screenWidth * 0.8,
height: 37.w,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(getEntranceBg("")),
fit: BoxFit.fill,
),
),
),
child: Row(
textDirection: TextDirection.ltr,
children: [
Padding(
padding: EdgeInsets.all(2.w),
child: netImage(
url: widget.msg.user?.userAvatar ?? "",
width: 28.w,
height: 28.w,
shape: BoxShape.circle,
child: Row(
textDirection: TextDirection.ltr,
children: [
Padding(
padding: EdgeInsets.all(2.w),
child: netImage(
url: widget.msg.user?.userAvatar ?? "",
width: 28.w,
height: 28.w,
shape: BoxShape.circle,
),
),
),
SizedBox(width: 5.w),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 2.w),
socialchatNickNameText(
maxWidth: 150.w,
widget.msg.user?.userNickname ?? "",
fontSize: 12.sp,
type: "",
needScroll:
(widget.msg.user?.userNickname?.characters.length ?? 0) >
15,
),
text(
SCAppLocalizations.of(context)!.enterTheRoom,
fontSize: 12.sp,
lineHeight: 0.9,
textColor: Colors.white,
),
],
),
],
SizedBox(width: 5.w),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 2.w),
socialchatNickNameText(
maxWidth: 150.w,
widget.msg.user?.userNickname ?? "",
fontSize: 12.sp,
type: "",
needScroll:
(widget.msg.user?.userNickname?.characters.length ??
0) >
15,
),
text(
SCAppLocalizations.of(context)!.enterTheRoom,
fontSize: 12.sp,
lineHeight: 0.9,
textColor: Colors.white,
),
],
),
],
),
),
),
);

View File

@ -1,5 +1,4 @@
import 'dart:collection';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
@ -212,6 +211,8 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
static const int _maxQueuedRequests = 24;
final Queue<_QueuedRoomGiftSeatFlightRequest> _queue = Queue();
final Map<String, ImageProvider<Object>> _imageProviderCache =
<String, ImageProvider<Object>>{};
final GlobalKey _overlayKey = GlobalKey();
late final AnimationController _controller;
@ -222,6 +223,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
ImageProvider<Object>? _activeImageProvider;
Offset? _activeTargetOffset;
bool _isPlaying = false;
int _sessionToken = 0;
@override
void initState() {
@ -254,6 +256,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_ensureCenterVisual(request);
_trimQueuedRequestsOverflow();
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
@ -278,6 +281,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
void _clear() {
_sessionToken += 1;
_queue.clear();
_controller.stop();
_controller.reset();
@ -287,6 +291,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_imageProviderCache.clear();
_isPlaying = false;
return;
}
@ -296,6 +301,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_imageProviderCache.clear();
_isPlaying = false;
});
}
@ -323,17 +329,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
for (final queuedRequest in requestsToRemove) {
_queue.remove(queuedRequest);
}
if (!_isPlaying && _queue.isEmpty) {
if (!mounted) {
_centerRequest = null;
_centerImageProvider = null;
return;
}
setState(() {
_centerRequest = null;
_centerImageProvider = null;
});
}
_syncIdleCenterVisual();
}
int _countTrackedRequests(String queueTag) {
@ -369,16 +365,22 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
if (_isPlaying || _queue.isEmpty || !mounted) {
return;
}
final scheduledToken = _sessionToken;
WidgetsBinding.instance.addPostFrameCallback((_) {
_startNextAnimation();
_startNextAnimation(expectedSessionToken: scheduledToken);
});
}
Future<void> _startNextAnimation() async {
if (!mounted || _isPlaying || _queue.isEmpty) {
Future<void> _startNextAnimation({int? expectedSessionToken}) async {
if (!mounted ||
_isPlaying ||
_queue.isEmpty ||
(expectedSessionToken != null &&
expectedSessionToken != _sessionToken)) {
return;
}
final activeSessionToken = _sessionToken;
final queuedRequest = _queue.removeFirst();
final targetOffset = _resolveTargetOffset(
queuedRequest.request.targetUserId,
@ -386,7 +388,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
if (targetOffset == null) {
if (queuedRequest.retryCount < 6) {
Future.delayed(const Duration(milliseconds: 120), () {
if (!mounted) {
if (!mounted || activeSessionToken != _sessionToken) {
return;
}
_queue.addFirst(
@ -395,6 +397,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_scheduleNextAnimation();
});
} else {
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
return;
@ -421,7 +424,9 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
).timeout(const Duration(milliseconds: 250));
} catch (_) {}
if (!mounted || _activeRequest != queuedRequest.request) {
if (!mounted ||
activeSessionToken != _sessionToken ||
_activeRequest != queuedRequest.request) {
return;
}
@ -430,28 +435,21 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
void _finishCurrentAnimation() {
final hasPendingRequests = _queue.isNotEmpty;
if (!mounted) {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
_syncIdleCenterVisual();
return;
}
setState(() {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
});
_syncIdleCenterVisual();
_scheduleNextAnimation();
}
@ -459,7 +457,10 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
if (_centerRequest != null && _centerImageProvider != null) {
return;
}
final imageProvider = _resolveImageProvider(request.imagePath);
final imageProvider = _resolveImageProvider(
request.imagePath,
request: request,
);
if (!mounted) {
_centerRequest = request;
_centerImageProvider = imageProvider;
@ -471,6 +472,39 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
});
}
void _syncIdleCenterVisual() {
if (_isPlaying || _activeRequest != null) {
return;
}
final nextRequest = _queue.isNotEmpty ? _queue.first.request : null;
final nextImageProvider =
nextRequest == null
? null
: _resolveImageProvider(
nextRequest.imagePath,
request: nextRequest,
);
if (!mounted) {
_centerRequest = nextRequest;
_centerImageProvider = nextImageProvider;
return;
}
final shouldUpdate =
_centerRequest != nextRequest ||
_centerImageProvider != nextImageProvider;
if (!shouldUpdate) {
return;
}
setState(() {
_centerRequest = nextRequest;
_centerImageProvider = nextImageProvider;
});
}
Offset? _resolveTargetOffset(String targetUserId) {
final overlayContext = _overlayKey.currentContext;
final targetKey = widget.resolveTargetKey(targetUserId);
@ -494,18 +528,34 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
return overlayBox.globalToLocal(globalTargetCenter);
}
ImageProvider<Object> _resolveImageProvider(String imagePath) {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return buildCachedImageProvider(imagePath);
ImageProvider<Object> _resolveImageProvider(
String imagePath, {
RoomGiftSeatFlightRequest? request,
}) {
final currentRequest = request ?? _activeRequest ?? _centerRequest;
final logicalSize =
currentRequest == null
? null
: currentRequest.beginSize > currentRequest.endSize
? currentRequest.beginSize
: currentRequest.endSize;
final cacheKey = '$imagePath|${logicalSize?.toStringAsFixed(2) ?? "auto"}';
final cachedProvider = _imageProviderCache[cacheKey];
if (cachedProvider != null) {
return cachedProvider;
}
if (imagePath.startsWith('/')) {
return FileImage(File(imagePath));
}
return AssetImage(imagePath);
final provider = buildCachedImageProvider(
imagePath,
logicalWidth: logicalSize,
logicalHeight: logicalSize,
);
_imageProviderCache[cacheKey] = provider;
return provider;
}
@override
Widget build(BuildContext context) {
final overlaySize = MediaQuery.sizeOf(context);
return IgnorePointer(
child: SizedBox.expand(
key: _overlayKey,
@ -518,11 +568,6 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
: 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 currentCenterRequest =
_centerRequest ?? _activeRequest;
@ -576,7 +621,9 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
);
}
return Stack(clipBehavior: Clip.none, children: children);
return RepaintBoundary(
child: Stack(clipBehavior: Clip.none, children: children),
);
},
),
),

View File

@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
@ -17,6 +18,20 @@ class LuckGiftNomorAnimWidget extends StatefulWidget {
}
class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
static const double _groupShiftXRatio = 26 / 422;
static const double _groupShiftYRatio = 18 / 422;
static const double _groupShiftYExtraUnits = 40;
static const double _avatarScale = 0.8;
static const double _avatarExtraShiftXUnits = -2;
static const double _avatarExtraShiftYUnits = 18;
static const double _avatarDiameterRatio = 76 / 422;
static const double _avatarLeftRatio = 152 / 422;
static const double _avatarTopRatio = 64 / 422;
static const double _awardTopRatio = 153 / 422;
static const double _awardXOffsetRatio = -13 / 422;
static const double _multipleTopRatio = 193 / 422;
static const double _multipleXOffsetRatio = -22 / 422;
String? _currentBurstEventId;
bool _isRewardAmountVisible = false;
@ -34,6 +49,48 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
return awardAmount.toString();
}
String _formatMultiple(num multiple) {
if (multiple % 1 == 0) {
return multiple.toInt().toString();
}
return multiple.toString();
}
Widget _buildSenderAvatar({
required String avatarUrl,
required double outerSize,
}) {
final avatarPadding = outerSize * 0.045;
final innerSize = outerSize - avatarPadding * 2;
return RepaintBoundary(
child: Container(
width: outerSize,
height: outerSize,
padding: EdgeInsets.all(avatarPadding),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFD680),
boxShadow: const [
BoxShadow(
color: Color(0x55243A74),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: ClipOval(
child: netImage(
url: avatarUrl,
width: innerSize,
height: innerSize,
fit: BoxFit.cover,
shape: BoxShape.circle,
),
),
),
);
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
@ -45,6 +102,28 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
_isRewardAmountVisible = false;
return const SizedBox.shrink();
}
final screenWidth = ScreenUtil().screenWidth;
final groupShiftX = screenWidth * _groupShiftXRatio;
final groupShiftY =
screenWidth * _groupShiftYRatio + _groupShiftYExtraUnits.w;
final avatarBaseDiameter = screenWidth * _avatarDiameterRatio;
final avatarDiameter = avatarBaseDiameter * _avatarScale;
final avatarInset = (avatarBaseDiameter - avatarDiameter) / 2;
final avatarLeft =
screenWidth * _avatarLeftRatio +
groupShiftX +
avatarInset +
_avatarExtraShiftXUnits.w;
final avatarTop =
screenWidth * _avatarTopRatio +
groupShiftY +
avatarInset +
_avatarExtraShiftYUnits.w;
final awardTop = screenWidth * _awardTopRatio + groupShiftY;
final awardXOffset = screenWidth * _awardXOffsetRatio + groupShiftX;
final multipleTop = screenWidth * _multipleTopRatio + groupShiftY;
final multipleXOffset =
screenWidth * _multipleXOffsetRatio + groupShiftX;
final rewardAnimationKey = ValueKey<String>(
'${rewardData.sendUserId ?? ""}'
'|${rewardData.acceptUserId ?? ""}'
@ -93,37 +172,83 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
},
),
),
if (_isRewardAmountVisible &&
(rewardData.userAvatar ?? '').trim().isNotEmpty)
Positioned(
left: avatarLeft,
top: avatarTop,
child: _buildSenderAvatar(
avatarUrl: (rewardData.userAvatar ?? '').trim(),
outerSize: avatarDiameter,
),
),
if (_isRewardAmountVisible)
Positioned.fill(
child: Align(
alignment: const Alignment(0, 0.12),
child: Transform.translate(
offset: Offset(0, 0),
Positioned(
top: awardTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(awardXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: ScreenUtil().screenWidth * 0.56,
maxWidth: screenWidth * 0.34,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: EdgeInsets.only(left: 6.w, right: 2.w),
child: Text(
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
),
child: Text(
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
),
),
),
),
),
),
),
if (_isRewardAmountVisible && (rewardData.multiple ?? 0) > 0)
Positioned(
top: multipleTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(multipleXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: screenWidth * 0.18,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'x${_formatMultiple(rewardData.multiple ?? 0)}',
maxLines: 1,
style: TextStyle(
fontSize: 17.sp,
color: const Color(0xFFF4F6FF),
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0x990B2D72),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
),
),

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/main.dart';
import 'package:marquee/marquee.dart';
@ -294,11 +296,7 @@ class _FloatingLuckGiftScreenWidgetState
TextSpan(text: "from ", style: baseStyle),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: netImage(
url: widget.message.giftUrl ?? "",
width: 18.w,
height: 18.w,
),
child: _buildGiftIcon(context),
),
],
),
@ -314,6 +312,57 @@ class _FloatingLuckGiftScreenWidgetState
);
}
Widget _buildGiftIcon(BuildContext context) {
final primaryGiftUrl = (widget.message.giftUrl ?? "").trim();
final fallbackGiftUrl = _resolveFallbackGiftUrl(context);
final displayGiftUrl =
primaryGiftUrl.isNotEmpty ? primaryGiftUrl : fallbackGiftUrl;
if (displayGiftUrl.isEmpty) {
return _buildGiftIconPlaceholder();
}
final backupUrl =
fallbackGiftUrl.isNotEmpty && fallbackGiftUrl != displayGiftUrl
? fallbackGiftUrl
: "";
return netImage(
url: displayGiftUrl,
width: 18.w,
height: 18.w,
borderRadius: BorderRadius.circular(3.w),
loadingWidget: _buildGiftIconPlaceholder(),
errorWidget:
backupUrl.isNotEmpty
? netImage(
url: backupUrl,
width: 18.w,
height: 18.w,
borderRadius: BorderRadius.circular(3.w),
noDefaultImg: true,
loadingWidget: _buildGiftIconPlaceholder(),
errorWidget: _buildGiftIconPlaceholder(),
)
: _buildGiftIconPlaceholder(),
);
}
String _resolveFallbackGiftUrl(BuildContext context) {
final giftId = (widget.message.giftId ?? "").trim();
if (giftId.isEmpty) {
return "";
}
final gift = Provider.of<SCAppGeneralManager>(
context,
listen: false,
).getGiftById(giftId);
return (gift?.giftPhoto ?? "").trim();
}
Widget _buildGiftIconPlaceholder() {
return SizedBox(width: 18.w, height: 18.w);
}
String _formatCoins(num? coins) {
final value = (coins ?? 0);
if (value > 9999) {

View File

@ -277,9 +277,11 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
provider.isMic = !provider.isMic;
provider.roomWheatMap.forEach((k, v) {
if (v.user?.id ==
AccountStorage().getCurrentUser()?.userProfile?.id &&
!provider.roomWheatMap[k]!.micMute!) {
final seat = provider.micAtIndexForDisplay(k);
if ((seat?.user?.id ?? "").trim() ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "")
.trim() &&
!(seat?.micMute ?? true)) {
if (!provider.isMic) {
provider.engine?.adjustRecordingSignalVolume(100);
provider.engine?.setClientRole(
@ -313,15 +315,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
}
bool _shouldShowMic(RtcProvider provider) {
var show = false;
provider.roomWheatMap.forEach((k, v) {
if (v.user?.id == AccountStorage().getCurrentUser()?.userProfile?.id) {
show = true;
}
});
return show;
return provider.isOnMai();
}
}

View File

@ -26,6 +26,7 @@ import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/gift/gift_page.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart';
import '../../../shared/business_logic/models/res/sc_user_identity_res.dart';
@ -265,11 +266,40 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
],
),
SizedBox(height: 2.w),
text(
"ID:${ref.userCardInfo?.userProfile?.getID() ?? 0}",
fontSize: 12.sp,
textColor: Colors.black,
fontWeight: FontWeight.bold,
SCSpecialIdBadge(
idText:
ref
.userCardInfo
?.userProfile
?.getID() ??
"",
showAnimated:
ref
.userCardInfo
?.userProfile
?.hasSpecialId() ??
false,
assetPath:
SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding:
EdgeInsets.fromLTRB(
33.w,
6.w,
16.w,
6.w,
),
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
),
],
),

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.2.0+4
environment:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

@ -3,6 +3,27 @@
## 当前总目标
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
## 本轮靓号动效替换(已完成)
- 已按 2026-04-21 最新靓号动效需求,把桌面“靓号动效”里的 `room_id / room_id_custom / user_id / user_id_4` 四套 `SVGA` 素材导入 Flutter 工程,并新增统一的靓号 ID 展示组件;当前会按 `ownSpecialId != null && expiredTime > now` 判定是否仍为有效靓号,过期靓号会自动回退到原始账号展示。
- 已完成 4 个指定场景的替换:`room_id` 现用于房间详情里的房间号,`room_id_custom` 现用于房间详情里的房主 ID`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;对应文案在命中靓号时会展示靓号号码,未命中时保持原始 `ID:` 文本样式。
## 本轮房间动效优化(进行中)
- 已按 2026-04-21 当前房间卡顿优化方案先落第一步“统一调度层”:新增房间特效调度器,在全屏 `VAP/SVGA` 高成本特效播放或排队期间,暂缓低优先级的房间进场动画、全局飘屏和座位飞屏入队,待高成本特效清空后按小间隔续播,先减少多条动画链路同一时刻抢主线程的情况。
- 本步只处理“调度时机”,没有改动现有动画内部逻辑:礼物/飞屏/飘屏的现有倍率触发规则、现有上限截断、`customAnimationCount` 现有循环方式和当前视觉表现均保持原样,避免第一步就引入联动回归。
- 已继续落第二步“局部重建/重绘隔离”:房间礼物播报条已从整层 `Consumer` 重建改成每个动画槽位独立 `Selector` 订阅,并把运行态数据拍平成快照,减少 `notifyListeners()` 时 4 个槽位整层一起重建;同时为礼物播报条、进场动画、座位飞屏和全局飘屏 overlay 补充 `RepaintBoundary`,先把高频位移动画和底层页面绘制隔离开,降低同屏重绘面积。
- 第二步同样没有改动任何动画业务规则:礼物播报条合并策略、幸运礼物奖励展示、现有队列时长、`customAnimationCount` 的逐个入队方式、现有上限和触发条件都保持原样,这一步只收敛 rebuild / repaint 成本。
- 已顺手补上高成本特效资源预热接线:当前全屏 `VAP/SVGA` 任务一入队就会触发已有的 preload 逻辑,让排队中的特效在前一个播放期间优先完成 `svga decode / 网络文件落缓存`,减少切到下一段时的首帧等待;这一步复用现有预热能力,不改变任何播放顺序和触发条件。
- 已继续补上图片链路的“按展示尺寸解码 + 排队阶段预热”:`buildCachedImageProvider` 现已支持 `network/file/asset` 三类资源统一按逻辑尺寸生成 provider并通过 `ResizeImage` 把解码尺寸压到实际展示大小;同时房间礼物播报条、进场动画、全局飘屏与座位飞屏在入队时会对头像/礼物图做异步预热,减少正式播到该条动画时首次解码和图片闪一下的概率。
- 这一轮同样没有改动任何动画规则或队列行为:只补资源命中率和解码尺寸控制,不变更现有播放次数、上限、倍率阈值、`customAnimationCount` 逐个飞行方式和当前视觉逻辑。
- 已继续收口座位飞屏的图片命中链路:当前在真正批量入队逐个飞行前,会先按飞行动画的大图尺寸预热一次礼物图;同时飞屏 overlay 内部会复用同路径同尺寸的 `ImageProvider`,减少 burst 场景里同一张礼物图连续多次构造 provider 和重复命中解析链路的开销。
- 已继续专项收口“顶部礼物信息播报”的连击卡顿:当前已把礼物播报条的“整条入场动画”和“右侧 `xN` 数量脉冲/续命”拆成独立状态,连击到来时不再通过重启整条 5 秒控制器来硬续播,而是只刷新数量脉冲并延长当前播报条的空闲存活时间;同时右侧数量已改为从当前显示值平滑补间到目标值,减少高频连击或本地批量合并时出现的数字跳变、断档和忽大忽小。
- 这一轮同样没有改礼物业务判断口径:未调整礼物连击的触发条件、上限、截断、倍率门槛和 `customAnimationCount` 等既有规则,只优化顶部播报条自身的显示状态机与数量刷新方式。
- 已继续修正连击播报条“单点送 1 个却直接跳 `+5`”的问题:顶部礼物播报条现已接入 `customAnimationCount` 的点击粒度信息,数量动画不再只按合并后的总量做补间,而是按“单次点击对应的数量步长”逐步递进;例如连续点击 5 次、每次送 1 个时,会按 `+1 +1 +1 +1 +1` 的视觉节奏补齐,而不是直接把右侧数量跳成 `+5`
- 已继续修正连击档位特效偶发“不播”的问题:当前连击 `SVGA` 触发已改为只命中仓库里真实存在资源的档位集合,不再把 `20/88/200...` 这类当前工程里没有对应特效文件的档位当成可播放资源;同时命中判断会按“本次新增后跨过的最高有效资源档位”来触发,避免因为本地批量窗口把多次点击合成一包后,先命中到空资源路径而看起来像整段特效都没播。
- 已按 2026-04-21 最新回归继续修正“飞向麦位的自制动画”缺失问题:座位飞屏现已从房间统一调度器的延后队列中移出,恢复为收到礼物消息后直接入队到全局 `RoomGiftSeatFlightOverlay`;保留既有的图片预热、同会话队列上限和 `customAnimationCount` 逐个飞行逻辑,只撤回此前对这一核心反馈动画的延迟调度,避免用户在礼物命中全屏特效或调度状态未及时清空时误以为飞屏动画消失。
- 已继续加固座位飞屏退出房间后的残留风险:全局 `RoomGiftSeatFlightOverlay` 现已和房间可视特效开关绑定,离开房间后不会再继续挂在首页/探索/消息/我的等页面上显示;同时 overlay 内部的 `postFrame / delayed retry / image precache` 回调已补上会话 token 校验,避免房间退出后旧动画任务把屏幕中央的静态礼物图再次拉起并长期悬挂。
- 已继续修正“房间飘屏在首页/探索/消息/我的也会刷到”的问题:根层房间礼物特效层现已统一改为依据 `RtcProvider.shouldShowRoomVisualEffects` 判断显示,而不再只看偏松的 `roomVisualEffectsEnabled`;同时 `OverlayManager` 在播放房间礼物/幸运礼物飘屏前会再次校验“当前仍在房间可视态且消息房间号与当前房间一致”,房间最小化或退出时也会主动清掉正在播和排队中的房间飘屏,避免房间内的 `5 times`、礼物飘屏等效果溢出到首页等非房间页面。
## 本轮启动优化(非网络)
- 已补回启动页正式展示逻辑:当前 `Weekly Star / Last Weekly CP` 两套自定义启动页都改为“本地有缓存才展示、无缓存不展示”;由于现阶段周榜与 CP 榜接口链路都还未 ready缓存刷新逻辑已先关闭所以当前启动阶段会直接回退到默认 splash不再展示这两套定制视觉稿。相关恢复入口已在缓存类里用 `api-ready-launch-splash` 注释标记,后续接口 ready 后可直接搜索接回。
- 已将启动页展示时间收敛为 `3` 秒,并在右上角新增通用 `skip 倒计时` 按钮:当前按钮会按秒级动态展示剩余时间,点击可立即跳过;文案已补齐 `en/ar/tr/bn` 多语言翻译,并按 locale 输出倒计时文本,便于后续继续做 RTL 语言验收。
@ -41,6 +62,18 @@
- 已继续修正幸运礼物 `burst` 中央金额文案的细节对位:当前已再上移 `2` 个单位,并给金额文本左侧补出额外留白,避免斜体 `+` 号因为字形外扩而贴边或被裁掉,确保 `+金币` 能完整落在特效中部。
- 已继续按最新联调口径修正幸运礼物 `burst` 中央金额文案:当前已去掉文本里的 `+` 字符,仅保留金币数本身显示;同时维持上一版向上微调后的纵向位置,让金额落点保持在特效中部偏上的稳定区域。
- 已继续收口幸运礼物 `burst` 金额文案与 `svga` 本体的时机同步:此前中央金币文本直接跟外层中奖数据显隐,而 `svga` 自身还存在资源加载和单次播放完成的生命周期,所以两者在出现/消失时会有肉眼可见的前后差;当前已给 `SCSvgaAssetWidget` 补上播放开始/结束回调,并让 `burst` 中央金额只在 `svga` 真正开始播时显示、在它播完清帧时一并隐藏。
- 已按 2026-04-21 最新联调继续细调幸运礼物 `burst` 中央金币文案:当前已在现有基础上再向上微调 `4` 个单位、向右微调 `5` 个单位,让金额更贴近特效中部的目标槽位;同时 `burst` 的整体展示时长也已再缩短 `1` 秒,避免命中后在屏上停留过久。
- 已继续修复幸运礼物顶部横幅 `from` 后礼物图标偶发显示不出来的问题:此前这条紫色 lucky gift 横幅只直接使用 socket 下发的 `giftCover/giftUrl` 渲染礼物图,一旦服务端该字段为空或图片加载失败,就只会退回默认占位;当前已把 `giftId` 一并挂进 `SCFloatingMessage`,并在 `FloatingLuckGiftScreenWidget` 内增加“优先用 `giftUrl`,失败时再回退到本地礼物列表缓存中的 `giftPhoto`”的双重兜底,避免横幅末尾再出现空白占位块。
- 已继续补齐幸运礼物横幅礼物图标的“晚到数据”刷新:此前 `from` 后面的礼物图只在横幅首次 build 时用 `listen: false` 读取一次本地礼物列表,若 `giftList` 之后才加载完成,这条 overlay 生命周期内也不会再更新,最终仍会留下空白图标;当前已改为通过 `Selector<SCAppGeneralManager, String>` 监听 `giftId -> giftPhoto` 回退图,并在 socket 未携带 `giftUrl` 时主动补拉一次 `giftList`,让礼物列表就绪后能把幸运礼物横幅末尾图标自动补出来。
- 已继续修正幸运礼物紫色横幅里 `from` 后礼物图“闪一下又空白”的问题:排查后发现这条横幅把礼物图标塞在 `Text.rich` 末尾,而正文又开启了单行省略,导致尾部 `WidgetSpan` 在最终排版溢出时可能被直接不绘制;当前已把礼物图标从内联文本里拆到正文右侧单独布局,保持正文自己省略、礼物图标固定显示,不再因为文本溢出把尾部礼物图一起吃掉。
- 已按 2026-04-21 最新回归把紫色幸运礼物横幅的礼物图取值收口到和其它飘屏同一来源:当前 `RTM` 在构造幸运礼物 `SCFloatingMessage` 前,会优先按 `giftId/standardId` 从本地礼物列表拿同款 `giftPhoto`,拿不到时才退回服务端下发的 `giftCover`;同时紫色横幅组件本身不再额外做自己的礼物图回退和二次解析,只直接显示已统一好的 `message.giftUrl`,避免和其它飘屏走出两套不同的取值链路。
- 已按 2026-04-21 最新排障请求给紫色幸运礼物横幅补齐定向日志:当前会在 `RTM` 解析 `GAME_LUCKY_GIFT` 时输出 `giftId / giftCover / 本地 id 命中 / 本地 standardId 命中 / resolvedUrl`,同时横幅组件自身也会记录一次 `init``gift icon render``gift icon url empty``gift icon load failed`,方便直接判断是服务端字段为空、本地映射错位,还是图片请求本身失败。
- 已根据 2026-04-21 最新日志结论继续收口幸运礼物紫色横幅:日志已确认这条横幅实际拿到的 `giftUrl` 正常且组件已进入渲染分支,因此当前继续把礼物图标的绘制方式直接对齐到其它飘屏的朴素路径,改为更大的 `netImage(url, width)` 直出并使用 `BoxFit.contain`,不再叠加此前那套更特殊的小图标 `borderRadius/noDefaultImg` 参数,优先排除该组件私有渲染参数导致的异常显示。
- 已继续绕开幸运礼物紫色横幅里可疑的小图 `ExtendedImage` 渲染链:当前礼物图标已改为在 `initState` 里先构建 `buildCachedImageProvider`,再通过基础 `Image(image: provider)` 直接绘制,并额外输出一次 `gift icon frame ready` 日志,用于确认这张图是否真的完成首帧解码显示;这样可以把问题进一步收敛为“图片 provider/解码”还是“组件布局/视觉观感”。
- 已按 2026-04-21 最新联调请求,把紫色幸运礼物横幅 `from` 后的图标临时替换为金币图标:这一步只用于快速验证该图标位本身的布局和可见性是否正常,不再受当前礼物图资源链路影响;如果金币图标能稳定显示,就说明问题仍集中在礼物图渲染链,而不是这个位置被遮挡或根本没画出来。
- 已按 2026-04-21 最新回归把 [floating_luck_gift_screen_widget] 的样式撤回到最初实现,不再继续在幸运礼物飘屏横幅上做偏题调试;同时已定位到真正缺图的是消息栏里的 `gameLuckyGift_5` 高亮消息,这条消息此前只写入了 `awardAmount/user/msg`,没有把 `gift` 一并塞进去,导致 `room_msg_item.dart` 读取 `widget.msg.gift?.giftPhoto` 时天然为空。当前已在 `RTM` 构造高亮 lucky 消息时补回 `giftPhoto`,让消息栏里的紫色 lucky message 能和其它消息一样拿到礼物图。
- 已按 2026-04-21 最新特效需求继续调整幸运礼物 `burst`:在 `luck_gift_reward_burst.svga` 下方那块淡色圆角矩形区域,补上了与 `SVGA` 同步进场/消失的中奖倍数文案;当前会和中央金额文本共用同一显隐时机,并在 `multiple > 0` 时展示为 `xN`,避免出现 `x0` 这类无效信息。
- 已继续按 2026-04-21 最新特效稿给幸运礼物 `burst` 补上中奖发送者头像:当前头像会出现在你标的黑块位置附近,并和 `SVGA` 使用同一套开始/结束显隐时机同步出现与消失;头像显示只读取当前中奖事件的 `userAvatar`,不改现有 `burst` 触发条件和播放时长。
- 已优化语言房麦位/头像的二次确认交互:普通用户点击可上麦的空麦位时,当前会直接执行上麦,不再先弹出只有 `Take the mic / Cancel` 的确认层;普通用户点击房间头像或已占麦位上的用户头像时,也会直接打开个人卡片,不再额外弹出仅含 `Open user profile card / Cancel` 的底部确认。房主/管理员仍保留原有带禁麦、锁麦、邀请上麦等管理动作的底部菜单,避免误删管理能力。
- 已继续收窄语言房个人卡片前的“确认意义”弹层:当前用户在麦位上点击自己的头像时,也会直接打开自己的个人卡片,不再先弹出仅包含 `Leave the mic / Open user profile card / Cancel` 的底部菜单;同时个人卡片内的“离开麦位”入口已替换为新的 `leave` 视觉素材,和最新房间交互稿保持一致。
- 已继续微调语言房个人卡片与送礼 UI个人卡片底部动作文案现已支持两行居中展示避免 `Leave the mic` 这类英文按钮被硬截断;房间底部礼物入口也已切换为新的本地 `SVGA` 资源 `room_bottom_gift_button.svga`,保持房间底栏视觉和最新动效稿一致。
@ -179,11 +212,18 @@
- `lib/shared/data_sources/models/enum/sc_gift_type.dart`
- `lib/shared/tools/sc_network_image_utils.dart`
- `lib/shared/tools/sc_gift_vap_svga_manager.dart`
- `lib/shared/tools/sc_room_effect_scheduler.dart`
- `lib/services/general/sc_app_general_manager.dart`
- `lib/shared/data_sources/sources/local/floating_screen_manager.dart`
- `lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart`
- `lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart`
- `lib/services/room/rc_room_manager.dart`
- `lib/services/gift/gift_animation_manager.dart`
- `lib/services/audio/rtc_manager.dart`
- `lib/modules/room/voice_room_page.dart`
- `lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart`
- `lib/ui_kit/widgets/room/anim/room_entrance_screen.dart`
- `lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart`
- `lib/modules/room/edit/room_edit_page.dart`
- `lib/modules/user/edit/edit_user_info_page2.dart`
- `lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart`
@ -205,6 +245,12 @@
- `lib/ui_kit/widgets/room/room_game_bottom_sheet.dart`
- `lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart`
- `lib/modules/room/detail/room_detail_page.dart`
- `lib/modules/room/online/room_online_page.dart`
- `lib/modules/user/me_page2.dart`
- `lib/modules/user/profile/person_detail_page.dart`
- `lib/shared/business_logic/models/res/login_res.dart`
- `lib/ui_kit/widgets/id/sc_special_id_badge.dart`
- `lib/ui_kit/widgets/room/room_user_info_card.dart`
- `lib/modules/home/popular/party/sc_home_party_page.dart`
- `lib/modules/home/popular/mine/sc_home_mine_skeleton.dart`
- `lib/modules/home/popular/follow/sc_room_follow_page.dart`
@ -225,6 +271,10 @@
- `lib/modules/user/my_items/theme/bags_theme_page.dart`
- `lib/ui_kit/widgets/store/store_bag_page_helpers.dart`
- `sc_images/general/sc_no_data.png`
- `sc_images/general/room_id.svga`
- `sc_images/general/room_id_custom.svga`
- `sc_images/general/user_id.svga`
- `sc_images/general/user_id_4.svga`
- `sc_images/splash/sc_weekly_star_bg.png`
- `sc_images/splash/sc_icon_weekly_star_rank_1.png`
- `sc_images/splash/sc_icon_weekly_star_rank_2.png`
@ -385,6 +435,13 @@
- Launch image 仍是默认占位资源,提交前建议替换
- `android/key.properties` 保存了本地 upload keystore 口令,必须自行妥善备份,且不要提交到仓库
- 由于已显式移除 Agora 本地屏幕共享相关组件,如果业务后续要启用“屏幕共享/录屏推流”,需要再单独恢复相关 manifest 声明
- `burst` 特效叠加层已停止用 `Align + translate` 猜偏移,改为按 `luck_gift_reward_burst.svga` 拆出的参考图层比例重排头像、中奖金额、倍数,重点对齐头像黑色占位圈和底部倍数圆角块
- 根据最新真机截图,`burst` 叠加层已整体继续向右下微调,先修正“整体偏左上”的问题,不改头像和文案尺寸
- 按最新口头要求,`burst` 叠加层又整体额外向下平移 `40` 个设计单位,继续保持头像、金额、倍数三者相对位置不变
- `burst` 头像已按最新要求缩到原来的 `80%`,并同步补偿左上坐标,保持头像中心点仍然对准原坑位
- `burst` 头像继续按最新口头要求单独微调:向下 `10` 个设计单位、向左 `2` 个设计单位,其余金额和倍数位置不动
- `burst` 头像又继续单独向下补移 `8` 个设计单位,当前累计为向下 `18`、向左 `2`
- 飞向麦位动画补了统一的“中心图空闲同步”逻辑:目标坐标重试失败放弃、队列被清空、上一段飞行结束后,都会把中心静态礼物图切到队列头或直接清空,避免残留卡死在屏幕中央
## 下一步要做什么
- 将新的 `AAB` 上传到 Play Console并在首发流程中继续使用 Google 管理的 app signing key。