bug修复以及ui替换

This commit is contained in:
roxy 2026-04-23 18:15:30 +08:00
parent 20106df6f0
commit fb86a9438c
32 changed files with 1739 additions and 1685 deletions

View File

@ -389,7 +389,8 @@
"camera": "كاميرا",
"system": "نظام",
"selectYourCountry": "اختر دولتك",
"inviteCode": "رمز الدعوة",
"enterInvitationCode": "أدخل رمز الدعوة",
"inviteCode": "رمز الدعوة",
"yesterday": "أمس {1}",
"monday": "الاثنين {1}",
"tuesday": "الثلاثاء {1}",

View File

@ -426,6 +426,7 @@
"deleteAccount": "অ্যাকাউন্ট মুছে ফেলুন",
"becomeAgent": "এজেন্ট হন",
"enterNickname": "উপনাম লিখুন",
"enterInvitationCode": "আমন্ত্রণ কোড লিখুন",
"selectYourCountry": "আপনার দেশ নির্বাচন করুন",
"inviteCode": "আমন্ত্রণ কোড",
"magic": "জাদু",

View File

@ -397,6 +397,7 @@
"deleteAccount": "Delete Account",
"becomeAgent": "Become an agent",
"enterNickname": "Enter Nickname",
"enterInvitationCode": "enter Invitation code",
"selectYourCountry": "Select your country",
"inviteCode": "Invite Code",
"magic": "Magic",

View File

@ -393,11 +393,12 @@
"userEditing": "Kullanıcı Düzenleme",
"enterTheUserId": "Kullanıcı ID'sini Girin",
"enterTheRoomId": "Oda ID'sini Girin",
"deleteAccount": "Hesabı Sil",
"becomeAgent": "Temsilci Ol",
"enterNickname": "Takma Ad Girin",
"selectYourCountry": "Ülkenizi Seçin",
"inviteCode": "Davet Kodu",
"deleteAccount": "Hesabı Sil",
"becomeAgent": "Temsilci Ol",
"enterNickname": "Takma Ad Girin",
"enterInvitationCode": "Davet kodunu girin",
"selectYourCountry": "Ülkenizi Seçin",
"inviteCode": "Davet Kodu",
"magic": "Sihir",
"luckGiftSpecialEffects": "Şanslı hediye animasyon efektleri",
"theVideoSizeCannotExceed": "Video boyutu 50M'yi geçemez",

View File

@ -502,7 +502,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = S9X2AJ2US9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -511,7 +511,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -693,7 +693,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -702,7 +702,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -731,7 +731,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -812,6 +812,12 @@ class SCAppLocalizations {
String get enterNickname => translate('enterNickname');
String get enterInvitationCode {
final value = translate('enterInvitationCode');
// Fallback to a readable English hint until every locale ships this key.
return value == 'enterInvitationCode' ? 'enter Invitation code' : value;
}
String get inviteCode => translate('inviteCode');
String get notifcation => translate('notifcation');

View File

@ -285,13 +285,15 @@ class YumiApplication extends StatefulWidget {
State<YumiApplication> createState() => _YumiApplicationState();
}
class _YumiApplicationState extends State<YumiApplication> {
class _YumiApplicationState extends State<YumiApplication>
with WidgetsBindingObserver {
late fluro.FluroRouter _router;
final SCDeepLinkHandler _deepLinkHandler = SCDeepLinkHandler();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
//
WidgetsBinding.instance.addPostFrameCallback((_) {
_initRouter();
@ -300,11 +302,22 @@ class _YumiApplicationState extends State<YumiApplication> {
}
@override
dispose() {
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_deepLinkHandler.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.detached) {
unawaited(
context.read<RtcProvider>().releaseRtcEngineForAppTermination(),
);
}
}
Future<void> _initLink() async {
//
await _deepLinkHandler.initDeepLinks(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -87,6 +87,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Map<num, MicRes> roomWheatMap = {};
RtcEngine? engine;
RtcEngineEventHandler? _rtcEngineEventHandler;
BuildContext? context;
RtmProvider? rtmProvider;
@ -749,71 +750,23 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Future<void> joinAgoraVoiceChannel() async {
try {
engine = await _initAgoraRtcEngine();
engine?.setAudioProfile(
final rtcEngine = await _initAgoraRtcEngine();
rtcEngine.setAudioProfile(
profile: AudioProfileType.audioProfileSpeechStandard,
scenario: AudioScenarioType.audioScenarioGameStreaming,
);
engine?.enableAudioVolumeIndication(
rtcEngine.enableAudioVolumeIndication(
interval: 500,
smooth: 3,
reportVad: true,
);
await engine?.disableVideo();
await engine?.setLocalPublishFallbackOption(
StreamFallbackOptions.streamFallbackOptionAudioOnly,
);
engine?.registerEventHandler(
RtcEngineEventHandler(
onError: (ErrorCodeType err, String msg) {
print('rtc错误${err}');
},
onAudioMixingStateChanged: (
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
isMusicPlaying = true;
break;
case AudioMixingStateType.audioMixingStateStopped:
case AudioMixingStateType.audioMixingStateFailed:
isMusicPlaying = false;
break;
default:
break;
}
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
print('rtc 自己加入 ${connection.channelId} ${connection.localUid}');
},
onUserJoined: (connection, remoteUid, elapsed) {
print('rtc用户 $remoteUid 加入了频道');
},
//
onUserOffline: (connection, remoteUid, reason) {
print('rtc用户 $remoteUid 离开了频道 (原因: ${reason})');
},
onTokenPrivilegeWillExpire: (
RtcConnection connection,
String token,
) async {
var rtcToken = await SCAccountRepository().getRtcToken(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
AccountStorage().getCurrentUser()?.userProfile?.id ?? "",
isPublisher: isOnMai(),
);
engine?.renewToken(rtcToken.rtcToken ?? "");
},
onAudioVolumeIndication: initializeAudioVolumeIndicationCallback,
),
);
await rtcEngine.disableVideo();
var rtcToken = await SCAccountRepository().getRtcToken(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
AccountStorage().getCurrentUser()?.userProfile?.id ?? "",
isPublisher: false,
);
await engine?.joinChannel(
await rtcEngine.joinChannel(
token: rtcToken.rtcToken ?? "",
channelId: currenRoom?.roomProfile?.roomProfile?.id ?? "",
uid: _resolveAgoraUidForCurrentUser(),
@ -832,7 +785,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
),
);
_syncSelfMicRuntimeState();
engine?.muteAllRemoteAudioStreams(roomIsMute);
rtcEngine.muteAllRemoteAudioStreams(roomIsMute);
} catch (e) {
SCTts.show("Join room fail");
exitCurrentVoiceRoomSession(false);
@ -841,14 +794,85 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
Future<RtcEngine> _initAgoraRtcEngine() async {
RtcEngine? engine;
while (engine == null) {
engine = createAgoraRtcEngine();
await engine.initialize(
RtcEngineContext(appId: SCGlobalConfig.agoraRtcAppid),
);
if (engine != null) {
return engine!;
}
final rtcEngine = createAgoraRtcEngine();
await rtcEngine.initialize(
RtcEngineContext(appId: SCGlobalConfig.agoraRtcAppid),
);
engine = rtcEngine;
_rtcEngineEventHandler = RtcEngineEventHandler(
onError: (ErrorCodeType err, String msg) {
print('rtc错误$err');
},
onAudioMixingStateChanged: (
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
isMusicPlaying = true;
break;
case AudioMixingStateType.audioMixingStateStopped:
case AudioMixingStateType.audioMixingStateFailed:
isMusicPlaying = false;
break;
default:
break;
}
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
print('rtc 自己加入 ${connection.channelId} ${connection.localUid}');
},
onUserJoined: (connection, remoteUid, elapsed) {
print('rtc用户 $remoteUid 加入了频道');
},
//
onUserOffline: (connection, remoteUid, reason) {
print('rtc用户 $remoteUid 离开了频道 (原因: $reason)');
},
onTokenPrivilegeWillExpire: (
RtcConnection connection,
String token,
) async {
var rtcToken = await SCAccountRepository().getRtcToken(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
AccountStorage().getCurrentUser()?.userProfile?.id ?? "",
isPublisher: isOnMai(),
);
engine?.renewToken(rtcToken.rtcToken ?? "");
},
onAudioVolumeIndication: initializeAudioVolumeIndicationCallback,
);
rtcEngine.registerEventHandler(_rtcEngineEventHandler!);
return rtcEngine;
}
Future<void> releaseRtcEngineForAppTermination() async {
final rtcEngine = engine;
if (rtcEngine == null) {
return;
}
final eventHandler = _rtcEngineEventHandler;
engine = null;
_rtcEngineEventHandler = null;
try {
if (eventHandler != null) {
rtcEngine.unregisterEventHandler(eventHandler);
}
await rtcEngine.leaveChannel();
} catch (e) {
print('rtc销毁前离开频道出错: $e');
}
try {
await rtcEngine.release();
} catch (e) {
print('rtc释放引擎出错: $e');
}
return engine;
}
void initializeAudioVolumeIndicationCallback(
@ -1291,7 +1315,6 @@ class RealTimeCommunicationManager extends ChangeNotifier {
rtmProvider?.cleanRoomData();
await engine?.leaveChannel();
await Future.delayed(Duration(milliseconds: 100));
await engine?.release();
await rtmProvider?.quitGroup(
currenRoom!.roomProfile?.roomProfile?.roomAccount ?? "",
);
@ -1325,11 +1348,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
await engine?.leaveChannel();
await Future.delayed(const Duration(milliseconds: 100));
await engine?.release();
} catch (e) {
print('rtc清理本地房间状态出错: $e');
} finally {
engine = null;
rtmProvider?.cleanRoomData();
_clearData();
notifyListeners();
@ -1339,7 +1360,6 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///
void _clearData() {
_stopRoomStatePolling();
engine = null;
_resetHeartbeatTracking();
_resetLocalAudioRuntimeTracking();
_disableMicListRefreshForCurrentSession = false;
@ -1370,6 +1390,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
SCGlobalConfig.resetVisualEffectSwitchesToRecommendedDefaults();
}
@override
void dispose() {
_stopRoomStatePolling();
unawaited(releaseRtcEngineForAppTermination());
super.dispose();
}
void toggleRemoteAudioMuteForAllUsers() {
roomIsMute = !roomIsMute;
engine?.muteAllRemoteAudioStreams(roomIsMute);

View File

@ -15,6 +15,7 @@ import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widg
import 'package:yumi/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
typedef FloatingScreenManager = OverlayManager;
@ -79,6 +80,19 @@ class OverlayManager {
logicalHeight: 96,
),
);
if (message.type == 0) {
unawaited(_warmLuckyGiftBannerSvga());
}
}
Future<void> _warmLuckyGiftBannerSvga() async {
try {
await SCSvgaAssetWidget.preloadAsset(
FloatingLuckGiftScreenWidget.backgroundSvgaAssetPath,
);
} catch (error) {
debugPrint('预热幸运礼物横幅SVGA失败: $error');
}
}
void _safeScheduleNext() {

View File

@ -1,37 +1,45 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svga/flutter_svga.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_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/main.dart';
import 'package:marquee/marquee.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
class FloatingLuckGiftScreenWidget extends StatefulWidget {
static const String backgroundSvgaAssetPath =
"sc_images/room/anim/luck_gift/kojuyee_reback_banner.svga";
final SCFloatingMessage message;
final VoidCallback onAnimationCompleted; //
const FloatingLuckGiftScreenWidget({
Key? key,
super.key,
required this.message,
required this.onAnimationCompleted,
}) : super(key: key);
});
@override
_FloatingLuckGiftScreenWidgetState createState() =>
State<FloatingLuckGiftScreenWidget> createState() =>
_FloatingLuckGiftScreenWidgetState();
}
class _FloatingLuckGiftScreenWidgetState
extends State<FloatingLuckGiftScreenWidget>
with TickerProviderStateMixin {
static const String _coinIconAssetPath = "sc_images/general/sc_icon_jb.png";
static const String _avatarDynamicKey = "Avatar";
static const String _textDynamicKey = "test";
static const String _multipleDynamicKey = "img_18";
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late AnimationController _swipeController; //
@ -158,196 +166,221 @@ class _FloatingLuckGiftScreenWidgetState
alignment: Alignment.center,
height: 83.w,
width: 350.w,
child: Stack(
children: [
Transform.flip(
flipX: SCGlobalConfig.lang == "ar" ? true : false, //
flipY: false, // false
child: Image.asset(
"sc_images/room/sc_icon_luck_gift_float_n_bg.png",
fit: BoxFit.fill,
),
),
Row(
mainAxisSize: MainAxisSize.min, //
children: [
Container(
margin: EdgeInsetsDirectional.only(top: 14.w, start: 1.w),
child: netImage(
url: widget.message.userAvatarUrl ?? "",
width: 52.w,
shape: BoxShape.circle,
),
),
SizedBox(width: 4.w),
Expanded(
child: Container(
margin: EdgeInsets.only(top: 10.w),
child: Row(
children: [
Container(
constraints: BoxConstraints(
maxWidth: 85.w,
maxHeight: 20.w,
),
child:
(widget.message.userName?.length ?? 0) > 6
? Marquee(
text: "${widget.message.userName} ",
style: TextStyle(
fontSize: 13.sp,
color: Color(0xffFEF129),
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
letterSpacing: 0.1,
),
scrollAxis: Axis.horizontal,
crossAxisAlignment:
CrossAxisAlignment.start,
blankSpace: 20.0,
velocity: 40.0,
pauseAfterRound: Duration(seconds: 1),
accelerationDuration: Duration(
seconds: 1,
),
accelerationCurve: Curves.easeOut,
decelerationDuration: Duration(
milliseconds: 500,
),
decelerationCurve: Curves.easeOut,
)
: Text(
"${widget.message.userName} ",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
color: Color(0xffFEF129),
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
),
),
),
SizedBox(width: 3.w),
Expanded(child: _buildRewardLine(context)),
SizedBox(width: 6.w),
Container(
width: 80.w,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 2.w),
buildNumForGame(
"${widget.message.multiple ?? 0}",
size: 25.w,
),
SizedBox(height: 3.w),
SCGlobalConfig.lang == "ar"
? Image.asset(
"sc_images/room/sc_icon_times_text_ar.png",
height: 12.w,
)
: Image.asset(
"sc_images/room/sc_icon_times_text_en.png",
height: 12.w,
),
],
),
),
SizedBox(width: 6.w),
],
),
),
),
],
),
],
child: Transform.flip(
flipX: SCGlobalConfig.lang == "ar" ? true : false,
flipY: false,
child: SCSvgaAssetWidget(
assetPath: FloatingLuckGiftScreenWidget.backgroundSvgaAssetPath,
width: 350.w,
height: 83.w,
fit: BoxFit.fill,
dynamicIdentity: [
widget.message.userAvatarUrl ?? "",
widget.message.userName ?? "",
"${widget.message.coins ?? 0}",
widget.message.giftUrl ?? "",
"${widget.message.multiple ?? 0}",
].join("|"),
movieConfigurer: _configureLuckGiftSvga,
),
),
),
),
);
}
Widget _buildRewardLine(BuildContext context) {
final baseStyle = TextStyle(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.1,
decoration: TextDecoration.none,
);
final amountStyle = baseStyle.copyWith(color: const Color(0xffFEF129));
return Text.rich(
TextSpan(
children: [
TextSpan(text: SCAppLocalizations.of(context)!.get, style: baseStyle),
TextSpan(
text: " ${_formatCoins(widget.message.coins)} ",
style: amountStyle,
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsetsDirectional.only(end: 3.w),
child: Image.asset(_coinIconAssetPath, width: 16.w, height: 16.w),
),
),
TextSpan(text: "from ", style: baseStyle),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _buildGiftIcon(context),
),
],
),
textAlign: TextAlign.start,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
strutStyle: StrutStyle(
height: 1.1,
fontWeight: FontWeight.bold,
forceStrutHeight: true,
),
);
}
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();
Future<void> _configureLuckGiftSvga(MovieEntity movieEntity) async {
final avatarUrl = (widget.message.userAvatarUrl ?? "").trim();
if (avatarUrl.isNotEmpty) {
try {
final avatarImage = await _loadUiImage(
avatarUrl,
logicalWidth: 64,
logicalHeight: 64,
);
if (avatarImage != null) {
movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey);
}
} catch (error) {
debugPrint("[LuckGiftSVGA] avatar sync failed: $error");
}
}
final backupUrl =
fallbackGiftUrl.isNotEmpty && fallbackGiftUrl != displayGiftUrl
? fallbackGiftUrl
: "";
try {
final textImage = await _buildBannerTextImage();
movieEntity.dynamicItem.setImage(textImage, _textDynamicKey);
} catch (error) {
debugPrint("[LuckGiftSVGA] text sync failed: $error");
}
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(),
final multipleValue = widget.message.multiple ?? 0;
if (multipleValue > 0) {
final numberPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: multipleValue.toString(),
style: const TextStyle(
fontSize: 36,
color: Color(0xffFFDC4A),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
decoration: TextDecoration.none,
),
),
)..layout();
final labelPainter = TextPainter(
textDirection: TextDirection.ltr,
text: const TextSpan(
text: "times",
style: TextStyle(
fontSize: 28,
color: Color(0xffFFDC4A),
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
height: 1,
decoration: TextDecoration.none,
),
),
)..layout();
movieEntity.dynamicItem.setDynamicDrawer((canvas, frameIndex) {
const centerX = 580.0;
numberPainter.paint(
canvas,
Offset(centerX - (numberPainter.width / 2), 18),
);
labelPainter.paint(
canvas,
Offset(centerX - (labelPainter.width / 2), 49),
);
}, _multipleDynamicKey);
}
}
Future<ui.Image> _buildBannerTextImage() async {
const double logicalWidth = 356;
const double logicalHeight = 42;
const double pixelRatio = 3.0;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
canvas.clipRect(const Rect.fromLTWH(0, 0, logicalWidth, logicalHeight));
final baseStyle = TextStyle(
fontSize: 25,
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: 0.1,
height: 1,
decoration: TextDecoration.none,
);
final highlightStyle = baseStyle.copyWith(
color: const Color(0xffFEF129),
fontWeight: FontWeight.w900,
);
final l10n = SCAppLocalizations.of(context)!;
final userName = (widget.message.userName ?? "").trim();
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: "",
text: TextSpan(
children: [
if (userName.isNotEmpty)
TextSpan(text: "$userName ", style: highlightStyle),
TextSpan(text: l10n.get, style: baseStyle),
TextSpan(
text: " ${_formatCoins(widget.message.coins)} ",
style: highlightStyle,
),
TextSpan(text: "from ", style: baseStyle),
],
),
)..layout(maxWidth: logicalWidth - 40);
const double startX = 6;
final textY = (logicalHeight - textPainter.height) / 2;
textPainter.paint(canvas, Offset(startX, textY));
double contentEndX = startX + textPainter.width;
final giftImage = await _loadUiImage(
_resolveDisplayGiftUrl(context),
logicalWidth: 108,
logicalHeight: 108,
);
if (giftImage != null) {
const giftSize = 36.0;
final giftRect = Rect.fromLTWH(
contentEndX,
(logicalHeight - giftSize) / 2,
giftSize,
giftSize,
);
paintImage(
canvas: canvas,
rect: giftRect,
image: giftImage,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
}
final picture = recorder.endRecording();
return picture.toImage(
(logicalWidth * pixelRatio).round(),
(logicalHeight * pixelRatio).round(),
);
}
String _resolveFallbackGiftUrl(BuildContext context) {
Future<ui.Image?> _loadUiImage(
String resource, {
double? logicalWidth,
double? logicalHeight,
}) async {
final target = resource.trim();
if (target.isEmpty) {
return null;
}
final provider = buildCachedImageProvider(
target,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
);
final stream = provider.resolve(ImageConfiguration.empty);
final completer = Completer<ui.Image?>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(imageInfo, synchronousCall) {
if (!completer.isCompleted) {
completer.complete(imageInfo.image);
}
stream.removeListener(listener);
},
onError: (Object error, StackTrace? stackTrace) {
if (!completer.isCompleted) {
completer.complete(null);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
stream.removeListener(listener);
return null;
},
);
}
String _resolveDisplayGiftUrl(BuildContext context) {
final primaryGiftUrl = (widget.message.giftUrl ?? "").trim();
if (primaryGiftUrl.isNotEmpty) {
return primaryGiftUrl;
}
final giftId = (widget.message.giftId ?? "").trim();
if (giftId.isEmpty) {
return "";
@ -359,10 +392,6 @@ class _FloatingLuckGiftScreenWidgetState
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) {
@ -373,160 +402,4 @@ class _FloatingLuckGiftScreenWidgetState
}
return value.toString();
}
///10000
Widget _buildGiftAnimation() {
return GestureDetector(
onTap: () {
debouncer.debounce(
duration: Duration(milliseconds: 350),
onDebounce: () {
if (widget.message.roomId != null &&
widget.message.roomId!.isNotEmpty) {
SCRoomUtils.goRoom(
widget.message.roomId!,
navigatorKey.currentState!.context,
fromFloting: true,
);
}
},
);
},
onHorizontalDragEnd: (details) {
//
final textDirection = Directionality.of(context);
//
double effectiveVelocity(double velocity) {
// RTL模式下
return textDirection == TextDirection.rtl ? -velocity : velocity;
}
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
if (velocity < 0) {
//
_handleSwipeLeft();
}
},
child: SlideTransition(
position: _isSwipeAnimating ? _swipeAnimation : _offsetAnimation,
child: Container(
alignment: Alignment.center,
height: 50.w,
width: 290.w,
margin: EdgeInsets.only(top: 20.w),
padding: EdgeInsets.symmetric(horizontal: 15.w),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage("sc_images/room/sc_icon_gift_float_bg.png"),
fit: BoxFit.fill,
),
),
child: Row(
mainAxisSize: MainAxisSize.min, //
children: [
//
if (widget.message.userAvatarUrl?.isNotEmpty ?? false)
head(url: widget.message.userAvatarUrl ?? "", width: 42.w),
SizedBox(width: 4.w),
Expanded(
child: Row(
children: [
Container(
constraints: BoxConstraints(
maxWidth: 48.w,
maxHeight: 21.w,
),
child:
(widget.message.userName?.length ?? 0) > 6
? Marquee(
text: widget.message.userName ?? "",
style: TextStyle(
fontSize: 13.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
),
scrollAxis: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start,
blankSpace: 20.0,
velocity: 40.0,
pauseAfterRound: Duration(seconds: 1),
accelerationDuration: Duration(seconds: 1),
accelerationCurve: Curves.easeOut,
decelerationDuration: Duration(
milliseconds: 500,
),
decelerationCurve: Curves.easeOut,
)
: Text(
widget.message.userName ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
),
),
),
text(
SCAppLocalizations.of(context)!.sendTo,
textColor: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.bold,
),
Container(
constraints: BoxConstraints(
maxWidth: 48.w,
maxHeight: 21.w,
),
child:
(widget.message.toUserName?.length ?? 0) > 6
? Marquee(
text: widget.message.toUserName ?? "",
style: TextStyle(
fontSize: 13.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
),
scrollAxis: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start,
blankSpace: 20.0,
velocity: 40.0,
pauseAfterRound: Duration(seconds: 1),
accelerationDuration: Duration(seconds: 1),
accelerationCurve: Curves.easeOut,
decelerationDuration: Duration(
milliseconds: 500,
),
decelerationCurve: Curves.easeOut,
)
: Text(
widget.message.toUserName ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
),
),
),
],
),
),
netImage(url: widget.message.giftUrl ?? "", width: 32.w),
SizedBox(width: 3.w),
Image.asset("sc_images/room/sc_icon_x.png", width: 18.w),
buildNum("${widget.message.number}", size: 18.w),
],
),
),
),
);
}
}

View File

@ -1,8 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svga/flutter_svga.dart';
typedef SCSvgaMovieConfigurer =
FutureOr<void> Function(MovieEntity movieEntity);
class SCSvgaAssetWidget extends StatefulWidget {
static final Map<String, MovieEntity> _cache = <String, MovieEntity>{};
static final Map<String, Future<MovieEntity>> _loadingTasks =
<String, Future<MovieEntity>>{};
const SCSvgaAssetWidget({
super.key,
required this.assetPath,
@ -17,6 +26,8 @@ class SCSvgaAssetWidget extends StatefulWidget {
this.clearsAfterStop = false,
this.onPlaybackStarted,
this.onPlaybackCompleted,
this.dynamicIdentity,
this.movieConfigurer,
});
final String assetPath;
@ -31,80 +42,14 @@ class SCSvgaAssetWidget extends StatefulWidget {
final bool clearsAfterStop;
final VoidCallback? onPlaybackStarted;
final VoidCallback? onPlaybackCompleted;
final Object? dynamicIdentity;
final SCSvgaMovieConfigurer? movieConfigurer;
@override
State<SCSvgaAssetWidget> createState() => _SCSvgaAssetWidgetState();
}
class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
with SingleTickerProviderStateMixin {
static final Map<String, MovieEntity> _cache = <String, MovieEntity>{};
static final Map<String, Future<MovieEntity>> _loadingTasks =
<String, Future<MovieEntity>>{};
late final SVGAAnimationController _controller;
String? _loadedAssetPath;
bool _hasError = false;
@override
void initState() {
super.initState();
_controller = SVGAAnimationController(vsync: this);
_controller.addStatusListener(_handleAnimationStatusChanged);
_loadAsset();
static Future<MovieEntity> preloadAsset(String assetPath) {
return _obtainMovieEntity(assetPath);
}
void _handleAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.forward) {
widget.onPlaybackStarted?.call();
return;
}
if (status == AnimationStatus.completed) {
widget.onPlaybackCompleted?.call();
}
}
@override
void didUpdateWidget(covariant SCSvgaAssetWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.assetPath != widget.assetPath) {
_loadAsset();
return;
}
if (oldWidget.active != widget.active || oldWidget.loop != widget.loop) {
_syncPlayback(
restartIfActive: widget.active && oldWidget.active != widget.active,
);
}
}
Future<void> _loadAsset() async {
final assetPath = widget.assetPath;
setState(() {
_hasError = false;
});
try {
final movieEntity = await _obtainMovieEntity(assetPath);
if (!mounted || widget.assetPath != assetPath) {
return;
}
setState(() {
_loadedAssetPath = assetPath;
_controller.videoItem = movieEntity;
});
_syncPlayback(restartIfActive: true);
} catch (error) {
debugPrint('[SCSVGA] load failed asset=$assetPath error=$error');
if (!mounted || widget.assetPath != assetPath) {
return;
}
setState(() {
_hasError = true;
});
}
}
Future<MovieEntity> _obtainMovieEntity(String assetPath) async {
static Future<MovieEntity> _obtainMovieEntity(String assetPath) async {
final cached = _cache[assetPath];
if (cached != null) {
return cached;
@ -138,6 +83,79 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
}
}
@override
State<SCSvgaAssetWidget> createState() => _SCSvgaAssetWidgetState();
}
class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
with SingleTickerProviderStateMixin {
late final SVGAAnimationController _controller;
String? _loadedAssetPath;
bool _hasError = false;
@override
void initState() {
super.initState();
_controller = SVGAAnimationController(vsync: this);
_controller.addStatusListener(_handleAnimationStatusChanged);
_loadAsset();
}
void _handleAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.forward) {
widget.onPlaybackStarted?.call();
return;
}
if (status == AnimationStatus.completed) {
widget.onPlaybackCompleted?.call();
}
}
@override
void didUpdateWidget(covariant SCSvgaAssetWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.assetPath != widget.assetPath ||
oldWidget.dynamicIdentity != widget.dynamicIdentity) {
_loadAsset();
return;
}
if (oldWidget.active != widget.active || oldWidget.loop != widget.loop) {
_syncPlayback(
restartIfActive: widget.active && oldWidget.active != widget.active,
);
}
}
Future<void> _loadAsset() async {
final assetPath = widget.assetPath;
setState(() {
_hasError = false;
});
try {
final movieEntity = await SCSvgaAssetWidget._obtainMovieEntity(assetPath);
if (widget.movieConfigurer != null) {
movieEntity.dynamicItem.reset();
await widget.movieConfigurer!(movieEntity);
}
if (!mounted || widget.assetPath != assetPath) {
return;
}
setState(() {
_loadedAssetPath = assetPath;
_controller.videoItem = movieEntity;
});
_syncPlayback(restartIfActive: true);
} catch (error) {
debugPrint('[SCSVGA] load failed asset=$assetPath error=$error');
if (!mounted || widget.assetPath != assetPath) {
return;
}
setState(() {
_hasError = true;
});
}
}
void _syncPlayback({bool restartIfActive = false}) {
if (_controller.videoItem == null) {
return;

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.2.0+4
version: 1.2.1+5
environment:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 155 KiB

View File

@ -0,0 +1,55 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_svga/src/proto/svga.pbserver.dart';
void main() {
test('dump svga catalog', () async {
const path =
'sc_images/room/anim/luck_gift/kojuyee_reback_banner.svga';
final rawBytes = File(path).readAsBytesSync();
final inflatedBytes = const ZLibDecoder().decodeBytes(rawBytes);
final movie = MovieEntity.fromBuffer(inflatedBytes);
final outputDir = Directory('/tmp/luck_gift_svga_catalog');
outputDir.createSync(recursive: true);
final metadata = <Map<String, Object?>>[];
for (final key in movie.images.keys) {
final bytes = movie.images[key]!;
File('${outputDir.path}/$key.png').writeAsBytesSync(bytes);
final sprites =
movie.sprites.where((sprite) => sprite.imageKey == key).toList();
final firstFrame = sprites.isNotEmpty && sprites.first.frames.isNotEmpty
? sprites.first.frames.first
: null;
metadata.add({
'key': key,
'bytes': bytes.length,
'spriteCount': sprites.length,
'layout': firstFrame == null
? null
: {
'x': firstFrame.layout.x,
'y': firstFrame.layout.y,
'width': firstFrame.layout.width,
'height': firstFrame.layout.height,
},
'transform': firstFrame == null
? null
: {
'a': firstFrame.transform.a,
'd': firstFrame.transform.d,
'tx': firstFrame.transform.tx,
'ty': firstFrame.transform.ty,
},
});
}
File('${outputDir.path}/metadata.json').writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(metadata),
);
});
}

View File

@ -86,6 +86,17 @@
- 已继续绕开幸运礼物紫色横幅里可疑的小图 `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-23 最新横幅替换需求,把房间顶部幸运礼物飘屏横幅的背景层从静态 `sc_icon_luck_gift_float_n_bg.png` 切到本地 `sc_images/room/anim/luck_gift/kojuyee_reback_banner.svga`,并补上 `SVGA` 预热,尽量减少首次出现时先闪旧底图或首帧等待的体感。
- 已在 2026-04-23 继续把这份 `SVGA` 做到可用级“解包”排查:当前已确认它属于 `zlib + protobuf` 结构,不是传统 zip 包;已从运行时结构里定位出 `Avatar``test` 两个可替换图片层,其中 `Avatar` 可直接承载头像,`test` 可承载主文案位。现已把幸运礼物顶部横幅改为优先把头像和主文案直接同步进这两个 `SVGA` 图层,避免外层 Flutter 叠加内容与 `SVGA` 自身位移不同步而越播越偏;同时保留旧版 `png + Row` 作为失败兜底,方便继续联调与回退。
- 已按 2026-04-23 最新验收反馈继续收口幸运礼物顶部横幅:当前已彻底移除这条横幅里的旧静态 `png` 兜底,不再在 `SVGA` 加载期或失败时回落显示原版静态图;同时已把 `test` 文案层的绘制尺寸、字号和内边距重新放大,避免文案缩成左侧一小条,后续继续围绕 `SVGA` 动态层本身微调,不再回退到外层静态叠图方案。
- 已按 2026-04-23 最新联调继续补齐幸运礼物顶部横幅的动态层内容:当前 `test` 图层不再只画主文案,而是会一起合成 `用户名 + get 金额 + from + 礼物图标 + 右侧倍数`;同时 `SVGA` 播放组件改回复用预热后的缓存实体,并在同一实例上重置/重灌动态层,减少每次重解整份 `SVGA` 带来的短暂空白,尽量让横幅保持“触发即播”的体感。
- 已按 2026-04-23 最新验收继续放大幸运礼物顶部横幅的动态文案层:当前 `test` 图层的主文案字号、礼物图标尺寸、右侧倍数字号以及整体承载高度都已继续上调,优先解决“能看见但看不清”的问题;本轮不改触发链路和数据口径,只做可读性增强。
- 已按 2026-04-23 最新验收继续微调幸运礼物顶部横幅尾部区域:当前礼物图标已继续放大,右侧倍数与 `times` 文案也同步加大,并整体右贴到横幅可用区域的最末端,优先吃掉此前尾部残留空位,让右侧视觉重心更贴稿。
- 已按 2026-04-23 最新验收把幸运礼物顶部横幅的右侧倍数从 `test` 文案图层里独立出来,改挂到整条横幅的全宽动态图层上单独绘制:这样右侧倍数不再受原文案层宽度限制,可以真正贴到横幅最末端;同时 `from` 后面的礼物图标也继续加大并缩短与文案的间距,优先解决“图标仍偏小、尾部空位仍过多”的问题。
- 已按 2026-04-23 最新验收继续修正幸运礼物顶部横幅右侧倍数“飞位”问题:当前已撤回那套不稳定的全宽动态图层定位,改为把倍数作为横幅容器内的独立右贴叠层直接固定到末端,优先确保位置稳定;同时 `from` 后面的礼物图标已按最新要求继续放大到更接近 `36` 的视觉尺寸,并进一步压缩与主文案的间距。
- 已按 2026-04-23 最新验收继续收口幸运礼物顶部横幅倍数的“时机不同步”问题:当前已撤回外层独立叠层做法,重新把倍数挂回 `SVGA` 主体条带自身的动态图层坐标系里,优先保证它和横幅本体共用同一条进场/位移时间轴,不再出现横幅在飘、倍数单独漂在上方的现象;礼物图标则继续维持 `36` 视觉尺寸并贴近 `from` 后侧。
- 已按 2026-04-23 最新验收继续微调幸运礼物顶部横幅右侧倍数的排版对齐:当前不再让上方数字和下方 `times` 各自按右边缘贴齐,而是改为共用同一条水平中心线绘制,优先修正“数字偏右、和下方文案看起来不在一列”的问题。
- 已按 2026-04-23 验收收尾要求移除房间右侧的幸运礼物临时验收入口:此前仅用于本地预览顶部 lucky gift 横幅的“测”按钮和本地造消息逻辑已从 `room_play_widget.dart / room_game_bottom_sheet.dart` 删除,房间侧边区恢复只保留正式游戏入口;幸运礼物横幅本身的 `SVGA`、动态头像/文案/倍数链路不做回退或改动。
- 已按 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` 的底部确认。房主/管理员仍保留原有带禁麦、锁麦、邀请上麦等管理动作的底部菜单,避免误删管理能力。