Compare commits

...

6 Commits

Author SHA1 Message Date
roxy
3d942de37b Merge branch 'develop' into feature 2026-06-24 10:09:08 +08:00
roxy
46b09eb51b 1631 2026-06-24 10:05:03 +08:00
roxy
085afce461 1.6.3 2026-06-17 19:28:08 +08:00
roxy
4b72a10cbf music bug fix 2026-06-15 16:21:05 +08:00
roxy
cd4957e6b5 1620 2026-06-12 15:50:51 +08:00
zhx
b5bea97c2a 修复 2026-06-12 14:44:06 +08:00
46 changed files with 1679 additions and 246 deletions

View File

@ -440,7 +440,12 @@ private class GiftMp4AlphaRenderer(
GLES20.glViewport(0, 0, viewWidth, viewHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
val viewport = contentViewport(viewWidth, viewHeight, layout.contentAspectRatio)
val viewport = contentViewport(
viewWidth,
viewHeight,
layout.contentAspectRatio,
cover = !layout.hasAlphaMask,
)
GLES20.glViewport(viewport.x, viewport.y, viewport.width, viewport.height)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
@ -610,6 +615,20 @@ private data class GiftMp4NativeLayout(
fun defaultLayout(): GiftMp4NativeLayout =
GiftMp4NativeLayout(GiftMp4Rect(0f, 0f, 1f, 1f), null)
fun normalLayout(width: Int, height: Int): GiftMp4NativeLayout {
val aspectRatio =
if (width > 0 && height > 0) {
width.toFloat() / height.toFloat()
} else {
1f
}
return GiftMp4NativeLayout(
GiftMp4Rect(0f, 0f, 1f, 1f),
null,
aspectRatio,
)
}
fun fromCreationParams(data: Any?): GiftMp4NativeLayout? = runCatching {
val map = data as? Map<*, *> ?: return null
val color = readLayoutRect(map["colorRect"]) ?: return null
@ -709,12 +728,15 @@ private data class GiftMp4NativeLayout(
val best = sortedCandidates.firstOrNull()
val runnerUpScore = sortedCandidates.drop(1).firstOrNull()?.score ?: 0.0
val selected =
if (best != null && (best.score - runnerUpScore >= 0.12 || best.score >= 0.35)) {
best.candidate
} else {
candidates.first()
}
best?.takeIf {
it.score - runnerUpScore >= 0.12 || it.score >= 0.35
}?.candidate
val bitmapWidth = bitmap.width
val bitmapHeight = bitmap.height
bitmap.recycle()
if (selected == null) {
return normalLayout(bitmapWidth, bitmapHeight)
}
return GiftMp4NativeLayout(selected.colorRect, selected.alphaRect)
}
@ -1056,12 +1078,18 @@ private data class GiftMp4FrameStats(
val colorScore: Double,
)
private fun contentViewport(width: Int, height: Int, aspectRatio: Float): GiftMp4Viewport {
private fun contentViewport(
width: Int,
height: Int,
aspectRatio: Float,
cover: Boolean,
): GiftMp4Viewport {
if (width <= 0 || height <= 0 || aspectRatio <= 0f) {
return GiftMp4Viewport(0, 0, width, height)
}
val viewAspect = width.toFloat() / height
return if (viewAspect > aspectRatio) {
val fitByHeight = if (cover) viewAspect <= aspectRatio else viewAspect > aspectRatio
return if (fitByHeight) {
val contentWidth = (height * aspectRatio).toInt().coerceAtLeast(1)
GiftMp4Viewport((width - contentWidth) / 2, 0, contentWidth, height)
} else {

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -64,7 +64,7 @@ post_install do |installer|
## 6. 禁用 Siri/助手 (NSSiriUsageDescription)
'PERMISSION_ASSISTANT=0',
## 7. 用 媒体库 (NSAppleMusicUsageDescription)
## 7. 用 媒体库 (NSAppleMusicUsageDescription)
'PERMISSION_MEDIA_LIBRARY=1',
## 8. 禁用 蓝牙 (如果你没用到蓝牙,建议也关掉)

View File

@ -11,11 +11,47 @@ PODS:
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- audio_session (0.0.1):
- Flutter
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/Auth (11.15.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 11.15.0)
@ -141,6 +177,9 @@ PODS:
- in_app_purchase_storekit (0.0.1):
- Flutter
- FlutterMacOS
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
- libpag (4.5.52)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
@ -170,6 +209,9 @@ PODS:
- pag (0.0.1):
- Flutter
- libpag
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
@ -191,6 +233,7 @@ PODS:
- Flutter
- FlutterMacOS
- SwiftyBeaver (1.9.5)
- SwiftyGif (5.4.5)
- tancent_vap (0.0.1):
- Flutter
- tencent_cloud_chat_sdk (8.0.0):
@ -223,8 +266,10 @@ PODS:
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
@ -235,9 +280,11 @@ DEPENDENCIES:
- image_cropper (from `.symlinks/plugins/image_cropper/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- on_audio_query_ios (from `.symlinks/plugins/on_audio_query_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pag (from `.symlinks/plugins/pag/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
@ -255,6 +302,8 @@ SPEC REPOS:
trunk:
- AppAuth
- AppCheckCore
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseAppCheckInterop
- FirebaseAuth
@ -282,6 +331,7 @@ SPEC REPOS:
- SDWebImage
- SDWebImageWebPCoder
- SwiftyBeaver
- SwiftyGif
- TOCropViewController
- TXCustomBeautyProcesserPlugin
- TXIMSDK_Plus_iOS_XCFramework
@ -290,10 +340,14 @@ SPEC REPOS:
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_auth:
:path: ".symlinks/plugins/firebase_auth/ios"
firebase_core:
@ -314,12 +368,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_purchase_storekit:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
just_audio:
:path: ".symlinks/plugins/just_audio/darwin"
on_audio_query_ios:
:path: ".symlinks/plugins/on_audio_query_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
pag:
:path: ".symlinks/plugins/pag/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
@ -349,8 +407,12 @@ SPEC CHECKSUMS:
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
firebase_auth: 50af8366c87bb88c80ebeae62eb60189c7246b9b
firebase_core: 995454a784ff288be5689b796deb9e9fa3601818
@ -378,6 +440,7 @@ SPEC CHECKSUMS:
image_cropper: 655b3ba703c9e15e3111e79151624d6154288774
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
libpag: 98742fad4b3ac2a2ee31e383d2483495fb9a5fb4
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
@ -385,6 +448,7 @@ SPEC CHECKSUMS:
on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pag: f01aa9017ab0e04a83ba4a20d6070a50f0ac9da8
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
@ -395,6 +459,7 @@ SPEC CHECKSUMS:
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
tancent_vap: fa8ad93814a9f950514a7074c662d2c937084c68
tencent_cloud_chat_sdk: 55e5fffe20f6b7937a26a674ccccb639563a9790
tencent_rtc_sdk: f77558b6b436a149d378557c2a837f73c09061bc
@ -408,6 +473,6 @@ SPEC CHECKSUMS:
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: a6f49a93e5f85201a2efdadcd7cf184b0e310894
PODFILE CHECKSUM: b724feb0f01aec7228c34cc4763485e911aa9322
COCOAPODS: 1.16.2

View File

@ -524,7 +524,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 162;
CURRENT_PROJECT_VERSION = 1631;
DEVELOPMENT_TEAM = S9YG87C297;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -533,7 +533,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 162;
CURRENT_PROJECT_VERSION = 1631;
DEVELOPMENT_TEAM = S9YG87C297;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -724,7 +724,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -744,7 +744,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 162;
CURRENT_PROJECT_VERSION = 1631;
DEVELOPMENT_TEAM = S9YG87C297;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -753,7 +753,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -163,6 +163,7 @@ private final class GiftMp4AlphaVideoView: UIView {
imageView.isOpaque = false
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
addSubview(imageView)
}
@ -191,6 +192,7 @@ private final class GiftMp4AlphaVideoView: UIView {
fileURL: url,
cacheKey: source.cacheKey
)
imageView.contentMode = sourceLayout.hasAlphaMask ? .scaleAspectFit : .scaleAspectFill
let item = AVPlayerItem(asset: asset)
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [

View File

@ -106,7 +106,7 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
if (success) {
userProvider?.updateUserAvatar(url);
}
});
}, allowGif: true);
},
),
SizedBox(height: 15.w),
@ -559,24 +559,6 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
return primary ?? fallback;
}
bool _isBrokenLocalMediaUrl(String? url) {
return (url ?? "").contains("/external/oss/local/");
}
String? _preferUsableAvatar(String? primary, String? fallback) {
if (primary != null &&
primary.isNotEmpty &&
!_isBrokenLocalMediaUrl(primary)) {
return primary;
}
if (fallback != null &&
fallback.isNotEmpty &&
!_isBrokenLocalMediaUrl(fallback)) {
return fallback;
}
return _preferNonEmpty(primary, fallback);
}
num? _preferNonZero(num? primary, num? fallback) {
if (primary != null && primary != 0) {
return primary;
@ -661,10 +643,6 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
if (submittedProfile != null) {
final mergedProfile = (user.userProfile ?? SocialChatUserProfile())
.copyWith(
userAvatar: _preferUsableAvatar(
user.userProfile?.userAvatar,
submittedProfile.userAvatar,
),
userNickname: _preferNonEmpty(
user.userProfile?.userNickname,
submittedProfile.userNickname,

View File

@ -174,7 +174,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
Provider.of<SCAppGeneralManager>(
context,
listen: false,
).giftList(includeCustomized: false);
).giftList(includeCustomized: true);
Provider.of<SCAppGeneralManager>(context, listen: false).giftActivityList();
unawaited(
Provider.of<SCAppGeneralManager>(
@ -271,7 +271,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
final availableTypes =
ref.giftByTab.entries
.where((entry) => entry.value.isNotEmpty)
.where((entry) => entry.key != "CUSTOMIZED")
.where((entry) => entry.key != _backpackGiftTab)
.map((entry) => entry.key)
.toList();

View File

@ -558,31 +558,37 @@ class _SwipeDeleteTileState extends State<_SwipeDeleteTile> {
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: Row(
children: [
SizedBox(width: constraints.maxWidth, child: widget.child),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
child: SizedBox(
width: constraints.maxWidth + actionWidth,
child: Row(
children: [
SizedBox(
width: constraints.maxWidth,
child: widget.child,
),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
),
),
),
),
),
],
],
),
),
),
),

View File

@ -330,9 +330,11 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
OverlayManager().removeRoom(roomId: currentRoomId);
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
if (!SCFloatIchart().isShow()) {
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
}
}
Widget _buildRoomStartupLayer() {

View File

@ -0,0 +1,303 @@
import 'dart:convert';
class HotgameBridgeActions {
static const String recharge = 'recharge';
static const String quit = 'quit';
static const String loadComplete = 'loadComplete';
static const String debugLog = 'debugLog';
static String normalize(String action) {
final trimmed = action.trim();
final lower = trimmed.toLowerCase();
switch (lower) {
case 'recharge':
case 'pay':
case 'gamepay':
case 'gamerecharge':
case 'insufficient':
case 'insufficientbalance':
return recharge;
case 'quit':
case 'exit':
case 'close':
case 'closegame':
case 'destroy':
return quit;
case 'loadcomplete':
case 'gameloaded':
case 'loaded':
case 'ready':
return loadComplete;
case 'debuglog':
return debugLog;
default:
return trimmed;
}
}
}
class HotgameBridgeMessage {
const HotgameBridgeMessage({
required this.action,
this.payload = const <String, dynamic>{},
});
final String action;
final Map<String, dynamic> payload;
static HotgameBridgeMessage fromAction(String action, String rawMessage) {
return HotgameBridgeMessage(
action: HotgameBridgeActions.normalize(action),
payload: _parsePayload(rawMessage),
);
}
static HotgameBridgeMessage parse(String rawMessage) {
try {
final dynamic decoded = jsonDecode(rawMessage);
if (decoded is Map<String, dynamic>) {
return HotgameBridgeMessage(
action: HotgameBridgeActions.normalize(
(decoded['action'] ??
decoded['cmd'] ??
decoded['event'] ??
decoded['method'] ??
decoded['type'] ??
'')
.toString(),
),
payload: _parsePayload(
decoded['payload'] ?? decoded['data'] ?? decoded['params'],
),
);
}
} catch (_) {}
return HotgameBridgeMessage(
action: HotgameBridgeActions.normalize(rawMessage),
);
}
static Map<String, dynamic> _parsePayload(dynamic rawPayload) {
if (rawPayload is Map<String, dynamic>) {
return rawPayload;
}
if (rawPayload is String) {
final trimmed = rawPayload.trim();
if (trimmed.isEmpty) {
return const <String, dynamic>{};
}
try {
final dynamic decoded = jsonDecode(trimmed);
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {}
return <String, dynamic>{'raw': trimmed};
}
return const <String, dynamic>{};
}
}
class HotgameJsBridge {
static const String channelName = 'HotgameBridgeChannel';
static String bootstrapScript({Map<String, dynamic>? launchPayload}) {
final safeLaunchPayload = jsonEncode(
launchPayload ?? const <String, dynamic>{},
);
return '''
(function() {
const launchPayload = $safeLaunchPayload;
function toPayload(input) {
if (typeof input === 'string') {
try {
return JSON.parse(input);
} catch (_) {
return input.trim() ? { raw: input.trim() } : {};
}
}
if (input && typeof input === 'object') {
return input;
}
return {};
}
function stringifyForLog(value) {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value.length > 320 ? value.slice(0, 320) + '...' : value;
}
try {
const text = JSON.stringify(value);
return text.length > 320 ? text.slice(0, 320) + '...' : text;
} catch (_) {
return String(value);
}
}
function postMessage(action, input) {
const payload = toPayload(input);
$channelName.postMessage(JSON.stringify({
action: action,
payload: payload
}));
return payload;
}
function postDebug(tag, payload) {
postMessage('${HotgameBridgeActions.debugLog}', {
tag: tag,
message: stringifyForLog(payload)
});
}
function dispatchLaunchPayload(payload) {
const normalizedPayload =
payload && typeof payload === 'object' ? payload : {};
const launchConfig =
normalizedPayload.launchConfig &&
typeof normalizedPayload.launchConfig === 'object'
? normalizedPayload.launchConfig
: {};
const entry =
normalizedPayload.entry && typeof normalizedPayload.entry === 'object'
? normalizedPayload.entry
: {};
window.__hotgameLaunchPayload = normalizedPayload;
window.hotgameLaunchPayload = normalizedPayload;
window.__hotgameLaunchConfig = launchConfig;
window.hotgameLaunchConfig = launchConfig;
window.__hotgameLaunchEntry = entry;
window.hotgameLaunchEntry = entry;
if (typeof window.onHotgameLaunchConfig === 'function') {
window.onHotgameLaunchConfig(launchConfig, normalizedPayload);
}
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(
new CustomEvent('hotgameLaunchConfig', { detail: launchConfig })
);
}
}
function installPostMessageObject(name, action) {
const existing = window[name];
if (!existing || typeof existing.postMessage !== 'function') {
window[name] = {
postMessage: function(body) {
return postMessage(action, body);
}
};
}
}
function installWebkitHandler(name, action) {
window.webkit = window.webkit || {};
window.webkit.messageHandlers = window.webkit.messageHandlers || {};
if (!window.webkit.messageHandlers[name] ||
typeof window.webkit.messageHandlers[name].postMessage !== 'function') {
window.webkit.messageHandlers[name] = {
postMessage: function(body) {
return postMessage(action, body);
}
};
}
}
function installJsBridgeFunction(name, action) {
window.JsBridge = window.JsBridge || {};
window.JsBridge[name] = function(body) {
return postMessage(action, body || {});
};
}
if (!window.__hotgameBridgeReady) {
window.__hotgameBridgeReady = true;
installJsBridgeFunction('recharge', '${HotgameBridgeActions.recharge}');
installJsBridgeFunction('quit', '${HotgameBridgeActions.quit}');
installJsBridgeFunction('exit', '${HotgameBridgeActions.quit}');
installJsBridgeFunction('closeGame', '${HotgameBridgeActions.quit}');
installPostMessageObject('recharge', '${HotgameBridgeActions.recharge}');
installPostMessageObject('quit', '${HotgameBridgeActions.quit}');
installPostMessageObject('loadComplete', '${HotgameBridgeActions.loadComplete}');
installWebkitHandler('recharge', '${HotgameBridgeActions.recharge}');
installWebkitHandler('quit', '${HotgameBridgeActions.quit}');
installWebkitHandler('loadComplete', '${HotgameBridgeActions.loadComplete}');
window.NativeBridge = window.NativeBridge || {};
window.NativeBridge.recharge = function(body) {
return postMessage('${HotgameBridgeActions.recharge}', body || {});
};
window.NativeBridge.quit = function(body) {
return postMessage('${HotgameBridgeActions.quit}', body || {});
};
window.NativeBridge.closeGame = function(body) {
return postMessage('${HotgameBridgeActions.quit}', body || {});
};
window.NativeBridge.rechargeSuccess = function(body) {
return notifyRechargeSuccess(body || {});
};
window.__hotgameBridgeDebug = postDebug;
postDebug('bridge.ready', {
href: window.location && window.location.href,
hasJsBridge: !!window.JsBridge,
hasRechargeSuccess: typeof window.rechargeSuccess === 'function'
});
}
function notifyRechargeSuccess(body) {
const payload = body || {};
let notified = false;
if (typeof window.rechargeSuccess === 'function') {
window.rechargeSuccess();
notified = true;
}
if (typeof window.onRechargeSuccess === 'function') {
window.onRechargeSuccess(payload);
notified = true;
}
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(
new CustomEvent('rechargeSuccess', { detail: payload })
);
}
return notified;
}
window.__hotgameNotifyRechargeSuccess = notifyRechargeSuccess;
dispatchLaunchPayload(launchPayload);
})();
''';
}
static String buildRechargeSuccessScript({Map<String, dynamic>? payload}) {
final safePayload = jsonEncode(
payload ??
<String, dynamic>{'timestamp': DateTime.now().millisecondsSinceEpoch},
);
return '''
(function() {
const payload = $safePayload;
if (typeof window.__hotgameBridgeDebug === 'function') {
window.__hotgameBridgeDebug('recharge.success', payload);
}
if (typeof window.__hotgameNotifyRechargeSuccess === 'function') {
window.__hotgameNotifyRechargeSuccess(payload);
return;
}
if (typeof window.rechargeSuccess === 'function') {
window.rechargeSuccess();
}
if (typeof window.onRechargeSuccess === 'function') {
window.onRechargeSuccess(payload);
}
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(
new CustomEvent('rechargeSuccess', { detail: payload })
);
}
})();
''';
}
}

View File

@ -127,6 +127,10 @@ class RoomGameListItemModel {
bool get isLeader =>
provider.toUpperCase() == 'LEADER' || gameType.toUpperCase() == 'LEADER';
bool get isHotgame =>
provider.toUpperCase() == 'HOTGAME' ||
gameType.toUpperCase() == 'HOTGAME';
factory RoomGameListItemModel.fromJson(Map<String, dynamic> json) {
return RoomGameListItemModel(
gameId: _asString(json['gameId']),

View File

@ -0,0 +1,640 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/hotgame_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/utils/room_game_viewport.dart';
import 'package:yumi/modules/room_game/views/baishun_loading_view.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/shared/tools/sc_h5_url_utils.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class HotgameGamePage extends StatefulWidget {
const HotgameGamePage({
super.key,
required this.roomId,
required this.game,
required this.launchModel,
});
final String roomId;
final RoomGameListItemModel game;
final BaishunLaunchModel launchModel;
@override
State<HotgameGamePage> createState() => _HotgameGamePageState();
}
class _HotgameGamePageState extends State<HotgameGamePage> {
static const double _sevenScreenRatio = 0.72;
final RoomGameRepository _repository = RoomGameRepository();
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
<Factory<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
};
late final WebViewController _controller;
Timer? _bridgeBootstrapTimer;
Timer? _loadingFallbackTimer;
bool _isLoading = true;
bool _isClosing = false;
bool _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false;
bool _didInjectProgressBridge = false;
int _bridgeInjectCount = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel(
HotgameJsBridge.channelName,
onMessageReceived: _handleBridgeMessage,
)
..addJavaScriptChannel(
HotgameBridgeActions.recharge,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
HotgameBridgeActions.recharge,
message,
),
)
..addJavaScriptChannel(
HotgameBridgeActions.quit,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
HotgameBridgeActions.quit,
message,
),
)
..addJavaScriptChannel(
HotgameBridgeActions.loadComplete,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
HotgameBridgeActions.loadComplete,
message,
),
)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
_log('page_started url=${_clip(url, 240)}');
_prepareForPageLoad();
},
onPageFinished: (String url) async {
_didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}');
await _injectBridge(reason: 'page_finished');
if (!mounted || _errorMessage != null) {
return;
}
setState(() {
_isLoading = false;
});
},
onProgress: (int progress) {
if (_didInjectProgressBridge ||
progress < 10 ||
_errorMessage != null) {
return;
}
_didInjectProgressBridge = true;
unawaited(_injectBridge(reason: 'progress_$progress'));
},
onWebResourceError: (WebResourceError error) {
_log(
'web_resource_error code=${error.errorCode} '
'type=${error.errorType} desc=${_clip(error.description, 300)}',
);
_stopBridgeBootstrap(reason: 'web_resource_error');
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.description;
_isLoading = false;
});
},
),
);
_log('init launch=${_stringifyForLog(_buildLaunchSummary())}');
unawaited(_loadGameEntry());
}
@override
void dispose() {
_stopBridgeBootstrap(reason: 'dispose');
super.dispose();
}
void _prepareForPageLoad() {
_didReceiveBridgeMessage = false;
_didFinishPageLoad = false;
_didInjectProgressBridge = false;
_bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
Timer timer,
) {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
timer.cancel();
return;
}
unawaited(_injectBridge(reason: 'bootstrap'));
if (_didFinishPageLoad && timer.tick >= 12) {
timer.cancel();
}
});
_loadingFallbackTimer = Timer(const Duration(seconds: 6), () {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
return;
}
setState(() {
_isLoading = false;
});
});
if (!mounted) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
}
void _stopBridgeBootstrap({String reason = 'manual'}) {
if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) {
_log('stop_bootstrap reason=$reason');
}
_bridgeBootstrapTimer?.cancel();
_bridgeBootstrapTimer = null;
_loadingFallbackTimer?.cancel();
_loadingFallbackTimer = null;
}
Future<void> _loadGameEntry() async {
try {
_prepareForPageLoad();
final entryUrl = widget.launchModel.entry.entryUrl.trim();
_log(
'load_entry launchMode=${widget.launchModel.entry.launchMode} '
'entryUrl=${_clip(entryUrl, 240)}',
);
if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) {
await _controller.loadHtmlString(_buildMockHtml());
return;
}
final h5Url = SCH5UrlUtils.appendToken(entryUrl);
final uri = Uri.tryParse(h5Url);
if (uri == null) {
throw Exception('Invalid hotgame entry url: $entryUrl');
}
await _controller.loadRequest(uri);
} catch (error) {
_log('load_entry_error error=${_clip(error.toString(), 400)}');
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.toString();
_isLoading = false;
});
}
}
Future<void> _injectBridge({String reason = 'manual'}) async {
_bridgeInjectCount += 1;
if (reason != 'bootstrap' ||
_bridgeInjectCount <= 3 ||
_bridgeInjectCount % 5 == 0) {
_log('inject_bridge reason=$reason count=$_bridgeInjectCount');
}
try {
await _controller.runJavaScript(
HotgameJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()),
);
} catch (error) {
_log(
'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}',
);
}
}
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
_log('channel_message raw=${_clip(message.message, 600)}');
final bridgeMessage = HotgameBridgeMessage.parse(message.message);
await _dispatchBridgeMessage(bridgeMessage);
}
Future<void> _handleNamedChannelMessage(
String action,
JavaScriptMessage message,
) async {
final bridgeMessage = HotgameBridgeMessage.fromAction(
action,
message.message,
);
_log('named_channel action=$action raw=${_clip(message.message, 600)}');
await _dispatchBridgeMessage(bridgeMessage);
}
Future<void> _dispatchBridgeMessage(
HotgameBridgeMessage bridgeMessage,
) async {
if (bridgeMessage.action == HotgameBridgeActions.debugLog) {
final tag = bridgeMessage.payload['tag']?.toString().trim();
final message = bridgeMessage.payload['message']?.toString() ?? '';
_log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}');
return;
}
_didReceiveBridgeMessage = true;
_stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}');
_log(
'bridge_action action=${bridgeMessage.action} '
'payload=${_stringifyForLog(_sanitizeForLog(bridgeMessage.payload))}',
);
switch (bridgeMessage.action) {
case HotgameBridgeActions.recharge:
await _openRechargeAndNotifyGame();
break;
case HotgameBridgeActions.quit:
await _closeAndExit(reason: 'h5_quit');
break;
case HotgameBridgeActions.loadComplete:
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = null;
});
break;
default:
_log('bridge_action_unhandled action=${bridgeMessage.action}');
break;
}
}
Future<void> _openRechargeAndNotifyGame() async {
_log('hotgame_recharge open_wallet');
await SCNavigatorUtils.push(context, WalletRoute.recharge);
try {
await _controller.runJavaScript(
HotgameJsBridge.buildRechargeSuccessScript(),
);
} catch (error) {
_log(
'recharge_success_notify_error error=${_clip(error.toString(), 300)}',
);
}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
setState(() {
_errorMessage = null;
_isLoading = true;
});
await _loadGameEntry();
}
Future<void> _closeAndExit({String reason = 'user_exit'}) async {
if (_isClosing) {
return;
}
_isClosing = true;
try {
final sessionId = widget.launchModel.gameSessionId.trim();
if (sessionId.isNotEmpty && !sessionId.startsWith('mock')) {
await _repository.closeGame(
provider: widget.game.provider,
roomId: widget.roomId,
gameSessionId: sessionId,
reason: reason,
params: const <String, dynamic>{},
);
}
} catch (error) {
_log('close_error error=${_clip(error.toString(), 300)}');
}
if (mounted) {
Navigator.of(context).pop();
}
}
String _resolveScreenMode() {
return resolveRoomGameScreenMode(
widget.game.launchParams,
fullScreen: widget.game.fullScreen,
);
}
int _resolveSafeHeight() {
if (widget.launchModel.entry.safeHeight > 0) {
return widget.launchModel.entry.safeHeight;
}
if (widget.game.safeHeight > 0) {
return widget.game.safeHeight;
}
return 0;
}
double _calculateBodyHeight(BuildContext context) {
final screenMode = _resolveScreenMode();
final maxDialogHeight = resolveRoomGameAvailableHeight(
context,
reservedTop: 12.w,
);
if (maxDialogHeight <= 0) {
return 0;
}
if (screenMode == 'full') {
return maxDialogHeight;
}
if (screenMode == 'seven') {
return maxDialogHeight * _sevenScreenRatio;
}
return clampRoomGameHeight(
_calculatePreferredBodyHeight(context),
maxDialogHeight,
);
}
double _calculatePreferredBodyHeight(BuildContext context) {
final mediaQuery = MediaQuery.maybeOf(context);
final screenSize = mediaQuery?.size ?? Size.zero;
final safeHeight = _resolveSafeHeight();
final fallback = screenSize.width;
if (safeHeight > 0 && screenSize.width > 0) {
final ratio = roomGameDesignWidth / safeHeight;
return screenSize.width / ratio;
}
return fallback;
}
void _log(String message) {}
String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.length <= keepStart + keepEnd) {
return trimmed;
}
return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}';
}
Object? _sanitizeForLog(Object? value, {String key = ''}) {
final lowerKey = key.toLowerCase();
final shouldMask =
lowerKey.contains('code') ||
lowerKey.contains('token') ||
lowerKey.contains('authorization');
if (value is Map) {
final result = <String, dynamic>{};
for (final MapEntry<dynamic, dynamic> entry in value.entries) {
result[entry.key.toString()] = _sanitizeForLog(
entry.value,
key: entry.key.toString(),
);
}
return result;
}
if (value is List) {
return value.map((Object? item) => _sanitizeForLog(item)).toList();
}
if (value is String) {
final normalized = shouldMask ? _maskValue(value) : value;
return _clip(normalized, 600);
}
return value;
}
String _clip(String value, [int limit = 600]) {
final trimmed = value.trim();
if (trimmed.length <= limit) {
return trimmed;
}
return '${trimmed.substring(0, limit)}...';
}
String _stringifyForLog(Object? value) {
if (value == null) {
return '';
}
try {
return _clip(jsonEncode(value), 800);
} catch (_) {
return _clip(value.toString(), 800);
}
}
Map<String, dynamic> _buildLaunchPayload() {
return <String, dynamic>{
'provider': widget.launchModel.provider,
'gameId': widget.launchModel.gameId,
'providerGameId': widget.launchModel.providerGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'entry': widget.launchModel.entry.toJson(),
'launchConfig': widget.launchModel.launchConfig.toJson(),
'roomState': widget.launchModel.roomState.toJson(),
'game': <String, dynamic>{
'gameId': widget.game.gameId,
'provider': widget.game.provider,
'gameType': widget.game.gameType,
'name': widget.game.name,
'launchMode': widget.game.launchMode,
'launchParams': widget.game.launchParams,
},
};
}
Map<String, dynamic> _buildLaunchSummary() {
final payload = _buildLaunchPayload();
return <String, dynamic>{
'roomId': widget.roomId,
'provider': widget.game.provider,
'gameId': widget.game.gameId,
'providerGameId': widget.launchModel.providerGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'entry': _sanitizeForLog(payload['entry']),
'launchConfig': _sanitizeForLog(payload['launchConfig']),
'roomState': _sanitizeForLog(payload['roomState']),
};
}
String _buildMockHtml() {
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { margin: 0; font-family: sans-serif; background: #111827; color: #fff; }
.wrap { padding: 24px; }
button {
margin: 8px 8px 0 0;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: #2563eb;
color: #fff;
}
#log { margin-top: 16px; white-space: pre-wrap; color: #bfdbfe; }
</style>
</head>
<body>
<div class="wrap">
<h3>Hotgame Mock</h3>
<button onclick="loadComplete.postMessage(null)">loadComplete</button>
<button onclick="window.JsBridge.recharge()">JsBridge.recharge()</button>
<button onclick="window.JsBridge.quit()">JsBridge.quit()</button>
<button onclick="window.webkit.messageHandlers.recharge.postMessage(null)">webkit recharge</button>
<button onclick="window.webkit.messageHandlers.quit.postMessage(null)">webkit quit</button>
<button onclick="window.rechargeSuccess && window.rechargeSuccess()">rechargeSuccess()</button>
<div id="log"></div>
</div>
<script>
window.rechargeSuccess = function() {
document.getElementById('log').textContent += '\\nrechargeSuccess called';
};
document.getElementById('log').textContent =
'launchConfig=' + JSON.stringify(window.hotgameLaunchConfig || {});
</script>
</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
final bodyHeight = _calculateBodyHeight(context);
final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
await _closeAndExit();
},
child: Material(
color: Colors.transparent,
child: Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.w),
topRight: Radius.circular(24.w),
),
child: Container(
width: ScreenUtil().screenWidth,
height: bodyHeight + bottomFrameHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
),
),
if (bottomFrameHeight > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomFrameHeight,
child: RoomGameCompatibilityBottomFrame(
height: bottomFrameHeight,
),
),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const IgnorePointer(
ignoring: true,
child: BaishunLoadingView(
message: 'Waiting for Hotgame...',
),
),
],
),
),
),
),
),
);
}
Widget _buildErrorState() {
return Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.88),
padding: EdgeInsets.symmetric(horizontal: 28.w),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Colors.white70, size: 34.w),
SizedBox(height: 12.w),
Text(
'Hotgame failed to load',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 8.w),
Text(
_errorMessage ?? '',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
),
SizedBox(height: 16.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: _reload, child: const Text('Retry')),
SizedBox(width: 10.w),
TextButton(
onPressed: () {
SCTts.show('Please verify the Hotgame entry url');
},
child: const Text('Tips'),
),
],
),
],
),
),
);
}
}

View File

@ -10,6 +10,7 @@ import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/utils/room_game_viewport.dart';
import 'package:yumi/modules/room_game/views/baishun_game_page.dart';
import 'package:yumi/modules/room_game/views/hotgame_game_page.dart';
import 'package:yumi/modules/room_game/views/leader_game_page.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
@ -33,8 +34,8 @@ class RoomGameListSheet extends StatefulWidget {
}
class _RoomGameListSheetState extends State<RoomGameListSheet> {
static const String _logPrefix = '[RoomGameLaunch]';
static const int _itemsPerRow = 4;
static const String _logPrefix = '[RoomGameList]';
static const String _sheetFrameAsset =
'sc_images/room/sc_room_game_sheet_frame.png';
static const String _dividerLayerAsset =
@ -92,7 +93,9 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
String targetGameId,
) {
final supportedItems =
items.where((item) => item.isBaishun || item.isLeader).toList();
items
.where((item) => item.isBaishun || item.isLeader || item.isHotgame)
.toList();
final candidates = supportedItems.isNotEmpty ? supportedItems : items;
if (targetGameId == '0') {
return candidates[math.Random().nextInt(candidates.length)];
@ -119,7 +122,7 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
SCTts.show('roomId is empty');
return;
}
if (!game.isBaishun && !game.isLeader) {
if (!game.isBaishun && !game.isLeader && !game.isHotgame) {
SCTts.show('This game provider is not wired yet');
return;
}
@ -273,6 +276,13 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
launchModel: launchModel,
);
}
if (game.isHotgame) {
return HotgameGamePage(
roomId: _roomId,
game: game,
launchModel: launchModel,
);
}
return null;
}

View File

@ -8,6 +8,7 @@ import 'package:yumi/shared/tools/sc_loading_manager.dart';
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/services/auth/user_profile_manager.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
@ -17,8 +18,11 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/modules/country/country_route.dart';
import '../../../shared/tools/sc_pick_utils.dart';
import '../../../shared/business_logic/models/res/country_res.dart';
import '../../../shared/business_logic/models/res/login_res.dart';
import '../../../shared/business_logic/models/res/sc_user_identity_res.dart';
import '../../../shared/business_logic/usecases/sc_accurate_length_limiting_textInput_formatter.dart';
class EditUserInfoPage2 extends StatefulWidget {
@ -37,6 +41,9 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
String? bornMonth;
String? autograph;
String? hobby;
String? countryId;
String? countryCode;
String? countryName;
String? bornDay;
String? bornYear;
String nickName = "";
@ -53,6 +60,9 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
nickName = currentProfile?.userNickname ?? "";
autograph = currentProfile?.autograph ?? "";
hobby = currentProfile?.hobby ?? "";
countryId = currentProfile?.countryId ?? "";
countryCode = currentProfile?.countryCode ?? "";
countryName = currentProfile?.countryName ?? "";
birthdayDate =
_birthdayFromProfile(currentProfile) ??
_birthdayFromAge(currentProfile?.age);
@ -63,6 +73,19 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
bornYear = _formatYear(currentProfile?.bornYear ?? birthdayDate?.year);
age = currentProfile?.age ?? _ageFromBirthday(birthdayDate);
sex = currentProfile?.userSex;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).getUserIdentity();
Provider.of<SCAppGeneralManager>(
context,
listen: false,
).fetchCountryList();
});
}
String? _preferNonEmpty(String? primary, String? fallback) {
@ -75,21 +98,6 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
return primary ?? fallback;
}
bool _isBrokenLocalMediaUrl(String? url) {
return (url ?? "").contains("/external/oss/local/");
}
String? _preferUsableAvatar(String? primary, String? fallback) {
if ((primary ?? "").trim().isNotEmpty && !_isBrokenLocalMediaUrl(primary)) {
return primary;
}
if ((fallback ?? "").trim().isNotEmpty &&
!_isBrokenLocalMediaUrl(fallback)) {
return fallback;
}
return _preferNonEmpty(primary, fallback);
}
DateTime? _birthdayFromProfile([SocialChatUserProfile? profile]) {
final targetProfile =
profile ?? AccountStorage().getCurrentUser()?.userProfile;
@ -157,11 +165,42 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
return values.any((value) => !identical(value, _noChange));
}
bool _hasIdentity(SCUserIdentityRes? identity) {
if (identity == null) {
return true;
}
return (identity.anchor ?? false) ||
(identity.bd ?? false) ||
(identity.agent ?? false) ||
(identity.bdLeader ?? false) ||
(identity.freightAgent ?? false) ||
(identity.superFreightAgent ?? false) ||
(identity.admin ?? false) ||
(identity.superAdmin ?? false) ||
(identity.manager ?? false) ||
(identity.yumiManager ?? false);
}
bool _canChangeCountry(SCUserIdentityRes? identity) {
return !_hasIdentity(identity);
}
String _countryDisplayText() {
final displayName = (countryName ?? "").trim();
if (displayName.isNotEmpty) {
return displayName;
}
return (countryCode ?? "").trim();
}
void _syncLocalProfileState(SocialChatUserProfile profile) {
userCover = _preferUsableAvatar(profile.userAvatar, userCover);
userCover = profile.userAvatar;
nickName = profile.userNickname ?? nickName;
autograph = profile.autograph ?? autograph;
hobby = profile.hobby ?? hobby;
countryId = profile.countryId ?? countryId;
countryCode = profile.countryCode ?? countryCode;
countryName = profile.countryName ?? countryName;
sex = profile.userSex ?? sex;
birthdayDate =
_birthdayFromProfile(profile) ??
@ -239,11 +278,9 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
String url,
) {
if (success) {
userCover = url;
setState(() {});
submitAvatarOnly();
submit(userAvatarValue: url);
}
});
}, allowGif: true);
},
),
SizedBox(height: 3.w),
@ -284,6 +321,12 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
_showInputBioHobby(hobby ?? "", 2);
},
),
if (_canChangeCountry(ref.userIdentity))
_buildItem(
"${SCAppLocalizations.of(context)!.countryRegion}:",
_countryDisplayText(),
_openCountryPicker,
),
],
);
},
@ -404,13 +447,6 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
return eighteenYearsAgo;
}
void submitAvatarOnly() {
if ((userCover ?? "").trim().isEmpty) {
return;
}
submit(userAvatarValue: userCover);
}
void submit({
Object? userAvatarValue = _noChange,
Object? userNicknameValue = _noChange,
@ -421,6 +457,7 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
Object? bornDayValue = _noChange,
Object? hobbyValue = _noChange,
Object? autographValue = _noChange,
Object? countryValue = _noChange,
}) async {
if (!_hasChanges([
userAvatarValue,
@ -432,6 +469,7 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
bornDayValue,
hobbyValue,
autographValue,
countryValue,
])) {
return;
}
@ -446,6 +484,8 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
_isSubmitting = true;
SCLoadingManager.show();
try {
final selectedCountry =
identical(countryValue, _noChange) ? null : countryValue as Country?;
final updatedProfile = await SCAccountRepository().updateUserInfo(
userAvatar:
identical(userAvatarValue, _noChange)
@ -471,14 +511,9 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
identical(autographValue, _noChange)
? null
: autographValue as String?,
countryId: selectedCountry?.id,
);
final mergedProfile = updatedProfile.copyWith(
userAvatar: _preferUsableAvatar(
updatedProfile.userAvatar,
identical(userAvatarValue, _noChange)
? userCover
: userAvatarValue as String?,
),
userNickname: _preferNonEmpty(
updatedProfile.userNickname,
identical(userNicknameValue, _noChange)
@ -516,6 +551,21 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
(identical(bornYearValue, _noChange)
? birthdayDate?.year
: bornYearValue as num?),
countryId: _preferNonEmpty(
updatedProfile.countryId,
selectedCountry?.id,
),
countryCode: _preferNonEmpty(
updatedProfile.countryCode,
selectedCountry?.alphaTwo,
),
countryName: _preferNonEmpty(
updatedProfile.countryName,
_preferNonEmpty(
selectedCountry?.countryName,
selectedCountry?.aliasName,
),
),
);
_syncLocalProfileState(mergedProfile);
if (!mounted) {
@ -529,13 +579,49 @@ class _EditUserInfoPage2State extends State<EditUserInfoPage2>
true;
setState(() {});
SCTts.show(SCAppLocalizations.of(context)!.operationSuccessful);
} catch (e) {
if (selectedCountry != null) {
AccountStorage().logout(context);
return;
}
} catch (_) {
// The repository/network layer already shows request errors, so keep the
// current edit page state and only release the submit/loading guards here.
} finally {
_isSubmitting = false;
SCLoadingManager.hide();
}
}
Future<void> _openCountryPicker() async {
final userIdentity =
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).userIdentity;
if (!_canChangeCountry(userIdentity)) {
return;
}
final generalManager = Provider.of<SCAppGeneralManager>(
context,
listen: false,
);
generalManager.clearCountrySelection();
await SCNavigatorUtils.push(context, CountryRoute.country, replace: false);
if (!mounted) {
return;
}
final selectedCountry = generalManager.selectCountryInfo;
if (selectedCountry == null) {
return;
}
final selectedCountryId = (selectedCountry.id ?? "").trim();
if (selectedCountryId.isEmpty ||
selectedCountryId == (countryId ?? "").trim()) {
return;
}
submit(countryValue: selectedCountry);
}
// ignore: unused_element
void _showSexDialog() {
showCenterDialog(

View File

@ -887,6 +887,8 @@ class _PersonDetailPageState extends State<PersonDetailPage> with RouteAware {
width: ScreenUtil().screenWidth,
height: 300.w,
fit: BoxFit.cover,
gifFit: BoxFit.cover,
gifAlignment: Alignment.topCenter,
),
);
}).toList(),
@ -2024,6 +2026,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> with RouteAware {
},
aspectRatio: ScreenUtil().screenWidth / 300.w,
backOriginalFile: false,
allowGif: true,
);
}
@ -2040,19 +2043,8 @@ class _PersonDetailPageState extends State<PersonDetailPage> with RouteAware {
final updatedProfile = await SCAccountRepository().updateUserInfo(
backgroundPhotos: [imageUrl],
);
final returnedPhotos = updatedProfile.backgroundPhotos ?? [];
final hasUploadedPhoto = returnedPhotos.any(
(photo) => (photo.url ?? "").trim() == imageUrl,
);
final backgroundPhotos =
hasUploadedPhoto
? returnedPhotos
: [PersonPhoto(url: imageUrl, status: 1)];
final mergedProfile = updatedProfile.copyWith(
backgroundPhotos: backgroundPhotos,
);
ref.userProfile = mergedProfile;
ref.syncCurrentUserProfile(mergedProfile);
ref.userProfile = updatedProfile;
ref.syncCurrentUserProfile(updatedProfile);
if (!mounted) {
return;
}

View File

@ -66,6 +66,7 @@ import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart';
typedef OnSoundVoiceChange = Function(num index, int volum);
typedef RoomMusicSessionExitListener = Future<void> Function();
typedef RtcProvider = RealTimeCommunicationManager;
enum RoomStartupStatus { idle, loading, ready, failed }
@ -468,6 +469,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
RoomRtcEngineAdapter? _roomRtcEngineAdapter;
RoomMusicMixingStateListener? _roomMusicMixingStateListener;
RoomMusicMicRouteChangedListener? _roomMusicMicRouteChangedListener;
RoomMusicSessionExitListener? _roomMusicSessionExitListener;
BuildContext? context;
RtmProvider? rtmProvider;
@ -2849,7 +2851,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
if (currenRoom != null) {
_switchAwayFromCurrentRoomForNewEntry();
await _switchAwayFromCurrentRoomForNewEntry();
}
if (!context.mounted) {
return;
@ -4854,8 +4856,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
exitRtmProvider?.cleanRoomData();
} catch (e) {}
await _notifyRoomMusicSessionExit();
_clearData(clearPersistedRoomMarker: false);
if (!isLogout && navigationContext != null) {
if (!isLogout && navigationContext != null && navigationContext.mounted) {
VoiceRoomRoute.popVoiceRoomToPrevious(navigationContext);
}
@ -4875,7 +4878,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
void _switchAwayFromCurrentRoomForNewEntry() {
Future<void> _switchAwayFromCurrentRoomForNewEntry() async {
_stopRoomStatePolling();
final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "";
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
@ -4889,6 +4892,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
exitRtmProvider?.cleanRoomData();
} catch (e) {}
await _notifyRoomMusicSessionExit();
_clearData(clearPersistedRoomMarker: false);
final roomRtcLeaveTask = _leaveRoomRtcForExit();
_pendingRoomSwitchRtcLeaveTask = roomRtcLeaveTask;
@ -6028,6 +6032,20 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_roomMusicMicRouteChangedListener = listener;
}
void setRoomMusicSessionExitListener(RoomMusicSessionExitListener? listener) {
_roomMusicSessionExitListener = listener;
}
Future<void> _notifyRoomMusicSessionExit() async {
final listener = _roomMusicSessionExitListener;
if (listener == null) {
return;
}
try {
await listener();
} catch (_) {}
}
void updateRoomMusicPublishingState({
required String? userId,
required bool publishing,

View File

@ -213,24 +213,28 @@ class SCAppGeneralManager extends ChangeNotifier {
}
}
String _displayGiftTab(String? giftTab) {
return giftTab == "EXCLUSIVE" ? "CUSTOMIZED" : (giftTab ?? "");
}
void _rebuildGiftTabs({required bool includeCustomized}) {
giftByTab.clear();
_giftByIdMap.clear();
_giftByStandardIdMap.clear();
for (var gift in giftResList) {
giftByTab[gift.giftTab];
var gmap = giftByTab[gift.giftTab];
final tab = _displayGiftTab(gift.giftTab);
var gmap = giftByTab[tab];
gmap ??= [];
gmap.add(gift);
giftByTab[gift.giftTab!] = gmap;
giftByTab[tab] = gmap;
var gAllMap = giftByTab["ALL"];
gAllMap ??= [];
if (gift.giftTab != "NSCIONAL_FLAG" &&
gift.giftTab != "ACTIVITY" &&
gift.giftTab != "LUCKY_GIFT" &&
gift.giftTab != "CP" &&
gift.giftTab != "CUSTOMIZED" &&
gift.giftTab != "MAGIC") {
if (tab != "NSCIONAL_FLAG" &&
tab != "ACTIVITY" &&
tab != "LUCKY_GIFT" &&
tab != "CP" &&
tab != "CUSTOMIZED" &&
tab != "MAGIC") {
gAllMap.add(gift);
}
giftByTab["ALL"] = gAllMap;

View File

@ -1,7 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:just_audio/just_audio.dart';
import 'package:on_audio_query/on_audio_query.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart';
@ -15,6 +19,10 @@ class LocalMusicScanner {
Future<List<SCMusicFolderMode>> scanMp3Folders({
List<SCMusicMode> addedSongs = const [],
}) async {
if (Platform.isIOS) {
return _pickIosMp3Files(addedSongs: addedSongs);
}
final hasPermission = await SCPermissionUtils.checkAudioPermission();
if (!hasPermission) {
return const <SCMusicFolderMode>[];
@ -96,6 +104,98 @@ class LocalMusicScanner {
return List<SCMusicFolderMode>.unmodifiable(folders);
}
Future<List<SCMusicFolderMode>> _pickIosMp3Files({
required List<SCMusicMode> addedSongs,
}) async {
final FilePickerResult? result;
try {
result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: const ['mp3'],
allowMultiple: true,
withData: false,
withReadStream: false,
);
} catch (_) {
return const <SCMusicFolderMode>[];
}
if (result == null || result.files.isEmpty) {
return const <SCMusicFolderMode>[];
}
final addedIds = addedSongs.map((item) => item.id).toSet();
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
final storageDir = await _iosRoomMusicDirectory();
final player = AudioPlayer();
final songs = <SCMusicMode>[];
final seen = <String>{};
try {
for (final file in result.files) {
final sourcePath = (file.path ?? '').trim();
if (sourcePath.isEmpty ||
!_isMp3FileName(file.name) ||
!_fileExists(sourcePath)) {
continue;
}
final sourceId = _firstNonEmpty([
file.identifier,
'${file.name}:${file.size}',
sourcePath,
]);
if (sourceId.isEmpty || !seen.add(sourceId)) {
continue;
}
final storedPath = await _copyIosPickedFile(
sourcePath: sourcePath,
fileName: file.name,
sourceId: sourceId,
storageDir: storageDir,
);
final durationMs = await _readLocalMp3DurationMs(player, storedPath);
final storedFile = File(storedPath);
final safeFileName = p.basename(storedPath);
songs.add(
SCMusicMode(
id: sourceId,
title: p.basenameWithoutExtension(file.name).trim(),
artist: '',
durationMs: durationMs,
localPath: storedPath,
contentUri: '',
folderPath: storageDir.path,
fileName: safeFileName,
size: storedFile.existsSync() ? storedFile.lengthSync() : file.size,
addedAt: DateTime.now().millisecondsSinceEpoch,
),
);
}
} finally {
await player.dispose();
}
if (songs.isEmpty) {
return const <SCMusicFolderMode>[];
}
songs.sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final addedCount =
songs.where((song) {
return addedIds.contains(song.id) ||
addedPaths.contains(song.localPath);
}).length;
return <SCMusicFolderMode>[
SCMusicFolderMode(
id: 'ios_file_picker',
name: 'Selected Music',
path: 'Files',
songs: List<SCMusicMode>.unmodifiable(songs),
addedCount: addedCount,
),
];
}
bool _isMp3(SongModel song) {
final extension = _songString(
song,
@ -108,6 +208,72 @@ class LocalMusicScanner {
_songString(song, "_data").toLowerCase().endsWith(".mp3");
}
bool _isMp3FileName(String fileName) {
return fileName.toLowerCase().endsWith('.mp3');
}
Future<Directory> _iosRoomMusicDirectory() async {
final supportDir = await getApplicationSupportDirectory();
final directory = Directory(p.join(supportDir.path, 'room_music'));
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return directory;
}
Future<String> _copyIosPickedFile({
required String sourcePath,
required String fileName,
required String sourceId,
required Directory storageDir,
}) async {
if (p.isWithin(storageDir.path, sourcePath)) {
return sourcePath;
}
final sourceFile = File(sourcePath);
final targetFile = File(
p.join(storageDir.path, _iosStoredFileName(fileName, sourceId)),
);
if (targetFile.existsSync() &&
targetFile.lengthSync() == sourceFile.lengthSync()) {
return targetFile.path;
}
await sourceFile.copy(targetFile.path);
return targetFile.path;
}
String _iosStoredFileName(String fileName, String sourceId) {
final baseName = p.basenameWithoutExtension(fileName).trim();
final safeBaseName =
baseName
.replaceAll(RegExp(r'[^A-Za-z0-9._ -]+'), '_')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final token = base64Url.encode(utf8.encode(sourceId)).replaceAll('=', '');
final suffix = token.length > 12 ? token.substring(0, 12) : token;
return '${safeBaseName.isEmpty ? 'music' : safeBaseName}_$suffix.mp3';
}
Future<int> _readLocalMp3DurationMs(AudioPlayer player, String path) async {
try {
final duration = await player.setFilePath(path);
await player.stop();
return duration?.inMilliseconds ?? 0;
} catch (_) {
return 0;
}
}
String _firstNonEmpty(List<String?> values) {
for (final value in values) {
final text = value?.trim() ?? '';
if (text.isNotEmpty) {
return text;
}
}
return '';
}
bool _fileExists(String path) {
try {
return File(path).existsSync();

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/room_rtc_types.dart';
@ -86,9 +87,11 @@ class RoomMusicManager extends ChangeNotifier {
}
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
_rtcProvider?.setRoomMusicSessionExitListener(null);
_rtcProvider = provider;
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
provider.setRoomMusicSessionExitListener(stopForRoomExit);
_syncPublishingState(force: true);
}
@ -364,6 +367,7 @@ class RoomMusicManager extends ChangeNotifier {
}
_isStarting = true;
try {
await _configureIosAudioSessionIfNeeded();
await _waitForStartInterval();
_lastLoopback = !rtcProvider.isOnMai();
await rtcProvider.startRoomMusicAudioMixing(
@ -404,6 +408,31 @@ class RoomMusicManager extends ChangeNotifier {
}
}
Future<void> _configureIosAudioSessionIfNeeded() async {
if (!Platform.isIOS) {
return;
}
try {
final session = await AudioSession.instance;
await session.configure(
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.mixWithOthers |
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.allowBluetoothA2dp,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
),
);
} catch (_) {
// TRTC can still manage the iOS audio session if this best-effort setup fails.
}
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
@ -554,6 +583,7 @@ class RoomMusicManager extends ChangeNotifier {
_publishingStateHeartbeatTimer?.cancel();
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
_rtcProvider?.setRoomMusicSessionExitListener(null);
super.dispose();
}
}

View File

@ -1,5 +1,4 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
@ -11,6 +10,8 @@ import 'package:yumi/shared/tools/sc_loading_manager.dart';
class SCPickUtils {
static final ImagePicker _pkr = ImagePicker();
static const int _defaultMaxImageBytes = 4000000;
static const int _gifOriginalMaxImageBytes = 10 * 1024 * 1024;
static void pickImage(
BuildContext context,
@ -18,27 +19,51 @@ class SCPickUtils {
double? aspectRatio,
bool? backOriginalFile = true,
bool? neeCrop = true,
bool allowGif = false,
bool skipCropForGif = true,
}) async {
try {
final XFile? pickedFile = await _pkr.pickImage(
source: ImageSource.gallery,
imageQuality: 90,
maxWidth: 1920,
maxHeight: 1080,
);
final XFile? pickedFile =
allowGif
? await _pkr.pickImage(source: ImageSource.gallery)
: await _pkr.pickImage(
source: ImageSource.gallery,
imageQuality: 90,
maxWidth: 1920,
maxHeight: 1080,
);
if (pickedFile == null) return;
if (!SCPathUtils.fileTypeIsPic(pickedFile.path)) {
SCTts.show("Please select sc_images in .jpg, .jpeg, .png format.");
final isGif = await _igif(pickedFile);
if (!context.mounted) {
onUpLoadCallBack(false, "");
return;
}
// GIF
if (await _igif(pickedFile)) {
SCTts.show("GIF sc_images are not supported");
if (isGif) {
if (!allowGif) {
SCTts.show("GIF sc_images are not supported");
return;
}
} else if (!SCPathUtils.fileTypeIsPic(pickedFile.path)) {
SCTts.show(
allowGif
? "Please select sc_images in .jpg, .jpeg, .png, .gif format."
: "Please select sc_images in .jpg, .jpeg, .png format.",
);
return;
}
final File imageFile = File(pickedFile.path);
if (isGif && skipCropForGif) {
await _uploadOriginalImage(
context,
imageFile,
onUpLoadCallBack,
maxBytes: _gifOriginalMaxImageBytes,
oversizeMessage: "The GIF image size cannot exceed 10M",
);
return;
}
if (neeCrop ?? false) {
cropImage(
imageFile,
@ -48,28 +73,45 @@ class SCPickUtils {
onUpLoadCallBack,
);
} else {
if (imageFile.lengthSync() > 4000000) {
SCTts.show(SCAppLocalizations.of(context)!.theImageSizeCannotExceed);
onUpLoadCallBack?.call(false, "");
return;
}
SCLoadingManager.show(context: context);
try {
String fileUrl = await SCGeneralRepositoryImp().upload(imageFile);
SCLoadingManager.hide();
onUpLoadCallBack?.call(true, fileUrl);
} catch (e) {
SCTts.show("upload fail $e");
onUpLoadCallBack?.call(false, "");
SCLoadingManager.hide();
}
await _uploadOriginalImage(
context,
imageFile,
onUpLoadCallBack,
maxBytes: _defaultMaxImageBytes,
oversizeMessage:
SCAppLocalizations.of(context)!.theImageSizeCannotExceed,
);
}
} catch (e) {
onUpLoadCallBack?.call(false, "");
onUpLoadCallBack(false, "");
SCTts.show("Failed to select image");
}
}
static Future<void> _uploadOriginalImage(
BuildContext context,
File imageFile,
OnUpLoadCallBack onUpLoadCallBack, {
required int maxBytes,
required String oversizeMessage,
}) async {
if (imageFile.lengthSync() > maxBytes) {
SCTts.show(oversizeMessage);
onUpLoadCallBack(false, "");
return;
}
SCLoadingManager.show(context: context);
try {
String fileUrl = await SCGeneralRepositoryImp().upload(imageFile);
SCLoadingManager.hide();
onUpLoadCallBack(true, fileUrl);
} catch (e) {
SCTts.show("upload fail $e");
onUpLoadCallBack(false, "");
SCLoadingManager.hide();
}
}
/// GIF
static Future<bool> _igif(XFile file) async {
try {
@ -165,7 +207,12 @@ class SCPickUtils {
if (pickedFile == null) return;
// GIF
if (await _igif(pickedFile)) {
final isGif = await _igif(pickedFile);
if (!context.mounted) {
onUpLoadCallBack(false, "");
return;
}
if (isGif) {
SCTts.show("Unsupported image format");
return;
}
@ -209,9 +256,6 @@ class SCPickUtils {
OnUpLoadCallBack onUpLoadCallBack, {
bool needUpload = true,
}) async {
if (originalImage == null) {
return;
}
await Navigator.push(
context,
MaterialPageRoute(

View File

@ -49,6 +49,7 @@ Widget head({
String? headdressCover,
BoxShape shape = BoxShape.circle,
BoxFit fit = BoxFit.cover,
BoxFit gifFit = BoxFit.contain,
BorderRadius? borderRadius,
bool showDefault = true,
bool isRoom = false,
@ -85,6 +86,7 @@ Widget head({
width: avatarWidth,
height: avatarHeight,
fit: fit,
gifFit: gifFit,
noDefaultImg: !showDefault,
defaultImg: "sc_images/general/sc_icon_avar_defalt.png",
),
@ -221,19 +223,28 @@ Widget netImage({
double? height,
BorderRadius? borderRadius,
BoxFit? fit,
BoxFit? gifFit,
AlignmentGeometry? gifAlignment,
BoxShape? shape,
Border? border,
Widget? loadingWidget,
Widget? errorWidget,
}) {
final isGif = SCPathUtils.getFileExtension(url) == ".gif";
final resolvedHeight = height ?? width;
final resolvedFit =
isGif ? (gifFit ?? fit ?? BoxFit.cover) : (fit ?? BoxFit.cover);
final resolvedAlignment =
isGif ? (gifAlignment ?? Alignment.center) : Alignment.center;
return ExtendedImage.network(
url,
width: width,
height: height ?? width,
cacheWidth: _resolveImageCacheDimension(width),
cacheHeight: _resolveImageCacheDimension(height ?? width),
height: resolvedHeight,
cacheWidth: isGif ? null : _resolveImageCacheDimension(width),
cacheHeight: isGif ? null : _resolveImageCacheDimension(resolvedHeight),
headers: buildNetworkImageHeaders(url),
fit: fit ?? BoxFit.cover,
fit: resolvedFit,
alignment: resolvedAlignment,
cache: true,
shape: shape ?? BoxShape.rectangle,
borderRadius: borderRadius,
@ -246,7 +257,10 @@ Widget netImage({
if (state.extendedImageLoadState == LoadState.completed) {
return ExtendedRawImage(
image: state.extendedImageInfo?.image,
fit: fit ?? BoxFit.cover,
width: width,
height: resolvedHeight,
fit: resolvedFit,
alignment: resolvedAlignment,
);
} else if (state.extendedImageLoadState == LoadState.failed) {
if (errorWidget != null) {

View File

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
@ -10,7 +8,6 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
@ -88,7 +85,6 @@ class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
reason: 'room_minimize',
);
SCGiftVapSvgaManager().stopPlayback();
unawaited(context.read<RoomMusicManager>().stopForRoomExit());
giftAnimationManager.cleanupAnimationResources();
SCFloatIchart().show();
VoiceRoomRoute.popVoiceRoomToPrevious(context);

View File

@ -65,6 +65,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
audio_session:
dependency: "direct main"
description:
name: audio_session
sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.3"
audioplayers:
dependency: "direct main"
description:
@ -209,14 +217,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@ -393,6 +393,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
file_selector_linux:
dependency: transitive
description:
@ -660,14 +668,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.2.14"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
google_identity_services_web:
dependency: transitive
description:
@ -724,14 +724,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
html:
dependency: transitive
description:
@ -947,6 +939,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.12.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.10.5"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:
@ -979,14 +995,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
marquee:
dependency: "direct main"
description:
@ -1035,14 +1043,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.17.6"
nested:
dependency: transitive
description:
@ -1051,14 +1051,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
@ -1177,13 +1169,13 @@ packages:
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
dependency: "direct overridden"
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@ -1320,14 +1312,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
pull_to_refresh:
dependency: "direct main"
description:
@ -1344,14 +1328,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
@ -1846,4 +1822,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
flutter: ">=3.38.0"

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.6.2+162
version: 1.6.3+1631
environment:
@ -136,15 +136,23 @@ dependencies:
flutter_svg: ^2.3.0
pag:
path: ./local_packages/pag-1.0.7-patched
just_audio: ^0.10.5
audio_session: ^0.2.3
file_picker: ^11.0.2
dev_dependencies:
flutter_launcher_icons: ^0.14.4
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
flutter_launcher_icons: ^0.14.4
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
dependency_overrides:
# path_provider_foundation 2.6.0 pulls in objective_c native assets that are
# currently packaged as simulator frameworks and rejected by App Store upload.
path_provider_foundation: 2.4.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:

View File

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/modules/room_game/bridge/hotgame_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
void main() {
test('normalizes documented hotgame recharge and quit bridge actions', () {
expect(HotgameBridgeActions.normalize('recharge'), 'recharge');
expect(HotgameBridgeActions.normalize('pay'), 'recharge');
expect(HotgameBridgeActions.normalize('gameRecharge'), 'recharge');
expect(HotgameBridgeActions.normalize('quit'), 'quit');
expect(HotgameBridgeActions.normalize('closeGame'), 'quit');
expect(HotgameBridgeActions.normalize('destroy'), 'quit');
});
test('parses hotgame bridge payload maps', () {
final message = HotgameBridgeMessage.parse(
'{"action":"gameRecharge","payload":{"source":"button"}}',
);
expect(message.action, HotgameBridgeActions.recharge);
expect(message.payload['source'], 'button');
});
test(
'bootstrap script contains documented hotgame client bridge methods',
() {
final script = HotgameJsBridge.bootstrapScript();
expect(script, contains('window.JsBridge'));
expect(script, contains("installJsBridgeFunction('recharge'"));
expect(script, contains("installJsBridgeFunction('quit'"));
expect(script, contains("installWebkitHandler('recharge'"));
expect(script, contains("installWebkitHandler('quit'"));
expect(script, contains('window.rechargeSuccess'));
},
);
test('detects hotgame room game list items', () {
const item = RoomGameListItemModel(
gameId: 'hg_1',
provider: 'HOTGAME',
gameType: 'HOTGAME',
providerGameId: 'Seven7',
name: 'Lucky77',
cover: '',
category: 'CHAT_ROOM',
sort: 1,
launchMode: 'H5_REMOTE',
fullScreen: false,
gameMode: 3,
safeHeight: 0,
orientation: 0,
packageVersion: '2026',
status: 'ENABLED',
launchParams: <String, dynamic>{'screenMode': 'seven'},
);
expect(item.isHotgame, isTrue);
expect(item.isBaishun, isFalse);
expect(item.isLeader, isFalse);
});
}