This commit is contained in:
roxy 2026-05-07 10:06:02 +08:00
parent fa516a94e1
commit 23748737a2
64 changed files with 7698 additions and 1819 deletions

View File

@ -5,6 +5,7 @@ import com.android.installreferrer.api.InstallReferrerStateListener
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.TimeZone
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private var installReferrerClient: InstallReferrerClient? = null private var installReferrerClient: InstallReferrerClient? = null
@ -20,6 +21,15 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MOBILE_CONTEXT_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"getTimeZoneIdentifier" -> result.success(TimeZone.getDefault().id)
else -> result.notImplemented()
}
}
} }
override fun onDestroy() { override fun onDestroy() {
@ -79,5 +89,6 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer" private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer"
private const val MOBILE_CONTEXT_CHANNEL = "com.org.yumiparty/mobile_context"
} }
} }

View File

@ -0,0 +1,378 @@
# 语音房大额礼物全局飘窗方案
整理时间2026-05-06
适用范围:语音房普通大额礼物飘窗;只在一级页面 `home / explore / message / me` 展示;点击进入对应房间。
本轮只做方案整理,不改业务代码。
## 1. 需求目标
在用户送出大额礼物时,新增一个顶部飘窗,展示类似:
> 用户 A 在房间 B 送了 C 礼物
要求:
- 礼物类型:普通礼物大额送礼,明确排除幸运礼物、魔法礼物等走 `giveLuckyGift` 的礼物。
- 展示范围:全局一级页面,但只限首页主框架内的四个 Tab`home / explore / message / me`
- 不在语音房页面、二级页面、设置页、充值页、聊天详情页等页面展示。
- 点击飘窗后进入具体房间。
- 飘窗位置沿用当前幸运礼物/礼物飘屏顶部位置。
## 2. 当前代码现状
### 2.1 顶部飘窗能力已存在
现有飘窗由 `OverlayManager` 统一调度:
- 文件:`lib/shared/data_sources/sources/local/floating_screen_manager.dart`
- 入口:`OverlayManager().addMessage(SCFloatingMessage)`
- 队列:`SCPriorityQueue<SCFloatingMessage>`,按 `priority` 排序。
- 位置:`OverlayEntry` 使用 `Align(topStart)`,再 `Transform.translate(offset: Offset(0, 70.w))`
- 动画组件:
- `type = 0``FloatingLuckGiftScreenWidget`
- `type = 1``FloatingGiftScreenWidget`
- `type = 2`:游戏中奖
- `type = 3`:火箭
- `type = 4`:红包
- `type = 5`VIP 进房
现有 `FloatingLuckGiftScreenWidget``FloatingGiftScreenWidget` 都已经支持:
- 从右往左飘过。
- 左滑快速关闭。
- 点击后调用 `SCRoomUtils.goRoom(roomId, context, fromFloting: true)` 进入房间。
### 2.2 现有普通礼物飘窗是房间内逻辑
普通礼物发送成功后会进入 `GiftPage.sendGiftMsg(...)`
- 文件:`lib/modules/gift/gift_page.dart`
- 当前判断:`gift.giftCandy * quantity > 9999` 时创建 `SCFloatingMessage(type: 1)`
- 当前内容:用户 A 送给用户 B 礼物,字段偏向 `toUserName / toUserAvatarUrl`
- 当前触发范围:发送者本地和当前房间 RTM 收到 `SCRoomMsgType.gift` 后触发。
这条逻辑不适合直接作为“全局大额礼物飘窗”:
- 它是房间消息,不是全服消息。
- 它按收礼人循环,可能一次多目标送礼触发多条。
- UI 文案是“send to 某用户”,不是“在房间 B 送了 C 礼物”。
- `OverlayManager._shouldDisplayMessage(...)` 当前对 `type = 0 / 1 / 5` 要求必须匹配当前房间 ID否则不展示。
### 2.3 全服广播通道已存在
RTM 初始化后会加入全服广播群:
- 文件:`lib/services/audio/rtm_manager.dart`
- `init(...)` 中调用 `joinBigBroadcastGroup()`
- 全服消息在 `_newBroadCastMsgRecv(...)` 处理
- 当前已处理:
- `GAME_LUCKY_GIFT`
- 游戏中奖
- 火箭
- 红包
因此新增大额普通礼物全局飘窗时,最合适的入口是全服广播群,而不是房间群消息。
## 3. 推荐总体方案
推荐采用:
```mermaid
flowchart LR
A["用户在房间送普通礼物"] --> B["后端校验送礼成功"]
B --> C{"是否普通大额礼物"}
C -- "否" --> D["只走现有房间送礼消息"]
C -- "是" --> E["后端推送 BIG_GIFT 到全服广播群"]
E --> F["客户端 _newBroadCastMsgRecv 接收"]
F --> G{"当前是否首页四个一级 Tab"}
G -- "否" --> H["丢弃,不入队"]
G -- "是" --> I["OverlayManager 入队展示顶部飘窗"]
I --> J["点击 SCRoomUtils.goRoom(roomId)"]
```
核心点:
- 大额判断放后端做,前端只负责展示,避免客户端伪造全服大礼物消息。
- 前端新增独立消息类型,例如 `BIG_GIFT`,不复用幸运礼物 `GAME_LUCKY_GIFT`
- 前端新增独立飘窗类型,例如 `SCFloatingMessage.type = 6`,避免影响当前房间内 `type = 1` 的普通礼物飘窗。
- 展示作用域由 `OverlayManager` 或主框架路由状态控制,只允许首页四 Tab 展示。
## 4. 服务端数据建议
建议后端在普通礼物接口送礼成功后判断是否需要推送全服广播:
- 接口链路:`/gift/batch`,前端对应 `SCChatRoomRepository.giveGift(...)`
- 排除链路:`/gift/give/lucky-gift`,前端对应 `giveLuckyGift(...)`
- 推荐阈值:沿用客户端现有房间内逻辑 `totalCoins >= 10000`,最终以产品/后端配置为准。
- 推荐只推送一次送礼行为,不按收礼人数量重复推送。
推荐全服广播 payload
```json
{
"type": "BIG_GIFT",
"data": {
"eventId": "giftSendRecordId or unique id",
"roomId": "room id",
"roomName": "room name",
"sendUserId": "sender user id",
"sendUserName": "sender nickname",
"sendUserAvatar": "sender avatar",
"giftId": "gift id",
"giftName": "gift name",
"giftPhoto": "gift cover url",
"giftQuantity": 1,
"giftCandy": 10000,
"totalCoins": 10000,
"timestamp": 1710000000000
}
}
```
字段说明:
- `eventId`:用于客户端去重。没有则用 `roomId + sendUserId + giftId + giftQuantity + timestamp` 做兜底 key。
- `roomId`:点击飘窗进房必须字段。
- `roomName`:满足“在房间 B”的展示需求。
- `giftName / giftPhoto`:满足“送了 C 礼物”的展示和礼物图标展示。
- `totalCoins`:可用于优先级、样式分级或埋点。
## 5. 前端改造点
### 5.1 新增大额礼物广播模型
建议新增模型:
- `lib/shared/business_logic/models/res/sc_big_gift_broadcast_push.dart`
字段对应服务端 payload。不要把所有字段硬塞进 `SCBroadCastLuckGiftPush`,幸运礼物模型语义不同。
也可以扩展 `SCFloatingMessage`
- 新增 `roomName`
- 新增 `giftName`
- 新增 `eventId`
- 保留 `roomId / userName / userAvatarUrl / giftUrl / giftId / number / coins / priority`
### 5.2 RTM 全服广播接入
`RealTimeMessagingManager._newBroadCastMsgRecv(...)` 增加:
```dart
} else if (type == "BIG_GIFT") {
final push = SCBigGiftBroadcastPush.fromJson(data);
_handleBigGiftGlobalNews(push);
}
```
`_handleBigGiftGlobalNews(...)` 推荐职责:
- 校验 `roomId``sendUserName``giftName` 至少有可展示值。
- 排除幸运礼物或服务端标记的非普通礼物。
- 去重。
- 组装 `SCFloatingMessage(type: 6, priority: 900, ...)`
- 调用 `OverlayManager().addMessage(...)`
优先级建议:
- 幸运礼物:当前 `priority = 1000`
- 大额普通礼物:建议 `priority = 900`
- 普通房间礼物本地飘窗:保持默认或现有逻辑。
### 5.3 新增大额礼物飘窗 Widget
建议新增:
- `lib/ui_kit/widgets/room/floating/floating_big_gift_screen_widget.dart`
原因:
- 当前 `FloatingGiftScreenWidget` 展示的是“用户 A sendTo 用户 B + 礼物图标 x 数量”。
- 新需求是“用户 A 在房间 B 送了 C 礼物”。
- 新增 Widget 可以复用现有动画、点击、左滑关闭逻辑,但不影响房间内普通礼物飘窗。
UI 建议:
- 保持同一位置和相近尺寸:`height 54.w` 左右,`width 320.w - 350.w`
- 左侧:送礼用户头像和昵称。
- 中间:房间名和文案,例如 `在 {roomName} 送出`
- 右侧:礼物图标 + 礼物名,可保留 `x{quantity}`
- 长昵称、长房间名、长礼物名使用 `TextOverflow.ellipsis` 或局部跑马灯,避免遮挡。
- RTL 语言需要检查布局方向,至少保持点击和左滑逻辑与现有组件一致。
`OverlayManager._buildScreenWidget(...)` 增加:
```dart
case 6:
return FloatingBigGiftScreenWidget(
message: message,
onAnimationCompleted: onComplete,
);
```
### 5.4 展示作用域:只允许首页四 Tab
当前 `OverlayManager` 是根 Overlay 展示,天然能覆盖全 App。需要新增“允许展示作用域”否则可能在二级页、房间页也展示。
推荐方案:由 `SCIndexPage` 作为主框架控制作用域。
实现思路:
- `SCIndexPage` 接入 `RouteAware`,订阅全局 `routeObserver`
- 当 `SCIndexPage` 是栈顶时,设置 `OverlayManager().setMainTabsVisible(true)`
- 当从首页 push 到任意二级页面时,`didPushNext` 设置为 false。
- 当从二级页返回首页时,`didPopNext` 设置为 true。
- `SCIndexPage.dispose` 设置为 false。
- `OverlayManager.addMessage(...)``_shouldDisplayMessage(...)``type = 6` 判断 `mainTabsVisible == true`,否则直接丢弃或不入队。
不推荐只用 `navigator.canPop()`
- 可以作为兜底,但语义不够清晰。
- 登录页、启动页也可能是根页面,不能等同于首页四 Tab。
### 5.5 调整现有展示过滤
当前 `OverlayManager._shouldDisplayMessage(...)``type = 0 / 1 / 5` 做当前房间匹配:
```dart
if (message.type != 0 && message.type != 1 && message.type != 5) {
return true;
}
...
return currentRoomId == messageRoomId;
```
新增 `type = 6` 时不要走当前房间匹配,而是走首页四 Tab 匹配:
- `type = 6`:只判断首页四 Tab 是否可见、全局飘屏开关是否开启、必要字段是否完整。
- `type = 0 / 1 / 5`:保留现有房间匹配,避免破坏当前房间内特效体验。
- `type = 2 / 3 / 4`:按现有逻辑评估是否也需要作用域限制,本需求不建议顺手改。
### 5.6 点击进房
新 Widget 点击继续复用:
```dart
SCRoomUtils.goRoom(
message.roomId!,
navigatorKey.currentState!.context,
fromFloting: true,
);
```
现有 `SCRoomUtils.goRoom(...)` 已处理:
- 当前没有房间:弹确认后进房。
- 当前房间最小化且房间相同:打开当前房间。
- 当前已在其他房间:弹确认切房。
这里不需要新增路由能力。
## 6. 客户端兜底方案
如果后端暂时不能推送 `BIG_GIFT`,可以做临时客户端兜底,但不推荐作为长期方案。
临时做法:
- 在 `GiftPage._executeGiftRequest(...)` 普通礼物成功后判断:
- `!request.isLuckyGiftRequest`
- `giftTab` 不是 `LUCK / LUCKY_GIFT / MAGIC`
- `gift.giftCandy * request.quantity >= threshold`
- 构造 `BigBroadcastGroupMessage("BIG_GIFT", SCFloatingMessage(...))`
- 调用 `RtmProvider.sendBigBroadcastGroup(...)`
- 必须只按一次送礼行为发送,不能放在 `sendGiftMsg(...)``acceptUsers` 循环里。
风险:
- 客户端可伪造全服广播,不够可信。
- 多端并发、弱网重试可能重复。
- 阈值和礼物类型判断容易与后端不一致。
因此正式方案仍建议后端推送。
## 7. 去重与队列策略
建议新增去重缓存:
- 位置:`RealTimeMessagingManager``OverlayManager`
- 结构:`LinkedHashMap<String, int>`,保存最近 100 条 event key 和时间。
- TTL30 秒到 60 秒。
- key 优先使用 `eventId`
队列策略:
- 继续使用 `OverlayManager` 现有优先级队列。
- 大额礼物 `priority = 900`,低于幸运礼物大奖 `1000`
- 可选:当队列过长时丢弃低优先级大额礼物,避免首页连续刷屏。
## 8. 开关策略
沿用现有全局飘屏开关:
- `SCGlobalConfig.isFloatingAnimationInGlobal`
- 本地持久化 key`FloatingAnimationInGlobal`
- 低性能设备会通过 `clampVisualEffectPreference(...)` 关闭。
建议:
- 大额礼物全局飘窗受这个开关控制。
- 不受 `isGiftSpecialEffects` 控制,因为它是全屏礼物特效开关。
- 不受 `isLuckGiftSpecialEffects` 控制,因为本需求不是幸运礼物。
## 9. 验收用例
### 9.1 基础展示
- 用户 A 在房间 B 送普通礼物 C总价值达到阈值。
- 当前用户在 `home`:看到顶部飘窗。
- 当前用户在 `explore`:看到顶部飘窗。
- 当前用户在 `message`:看到顶部飘窗。
- 当前用户在 `me`:看到顶部飘窗。
### 9.2 范围限制
- 当前用户在语音房页面:不展示该全局大额礼物飘窗。
- 当前用户在个人资料页、设置页、聊天详情页、充值页:不展示。
- App 在启动页或登录页:不展示。
### 9.3 礼物类型
- 普通礼物达到阈值:展示。
- 普通礼物未达到阈值:不展示。
- 幸运礼物或魔法礼物达到金额:不走 `BIG_GIFT`,继续走现有幸运礼物逻辑。
- 多人收礼的一次送礼:只展示一条。
### 9.4 点击进房
- 当前无房间:点击飘窗,确认后进入房间 B。
- 当前房间已最小化且是房间 B点击后打开当前房间。
- 当前已在其他房间:点击后弹切房确认。
- `roomId` 为空或房间不存在:不展示或进房失败时走现有错误提示。
### 9.5 稳定性
- 连续多条大额礼物:按队列顺序展示,不重叠。
- 同一 `eventId` 重复到达:只展示一次。
- 左滑关闭后下一条能继续展示。
- 关闭全局飘屏开关后不展示。
- 低性能设备默认不展示。
## 10. 建议实施顺序
1. 和后端确认 `BIG_GIFT` 全服广播协议、阈值、是否包含 `roomName / giftName / giftPhoto / eventId`
2. 新增 `SCBigGiftBroadcastPush` 模型和 `SCFloatingMessage` 扩展字段。
3. 新增 `FloatingBigGiftScreenWidget`,复用现有动画、点击、左滑行为。
4. `OverlayManager` 支持 `type = 6`,并新增首页四 Tab 可见性控制。
5. `SCIndexPage``RouteAware` 控制 `OverlayManager` 的主 Tab 展示作用域。
6. `RtmProvider._newBroadCastMsgRecv(...)` 接入 `BIG_GIFT`
7. 加去重和队列上限保护。
8. 做手动验收与必要 Widget/单元测试。
## 11. 需要确认的问题
- 大额阈值最终是多少:沿用 `10000`,还是后端配置?
- 飘窗是否展示收礼人:当前需求文案不需要,只写“用户 A 在房间 B 送了 C 礼物”。
- 多目标送礼是否只展示一次:建议只展示一次。
- 如果发送者自己也在首页四 Tab是否也展示自己刚送的大额礼物建议展示和全服广播一致如产品不希望自显需要后端或前端按 `sendUserId == currentUserId` 过滤。
- 房间 B 名称为空时的兜底文案:建议使用 `roomId` 或本地化的“语音房”。

View File

@ -10,9 +10,28 @@ import UIKit
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
registerDurableAuthStorageChannel() registerDurableAuthStorageChannel()
registerMobileContextChannel()
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
private func registerMobileContextChannel() {
guard let controller = window?.rootViewController as? FlutterViewController else {
return
}
let channel = FlutterMethodChannel(
name: "com.org.yumiparty/mobile_context",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { call, result in
switch call.method {
case "getTimeZoneIdentifier":
result(TimeZone.current.identifier)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func registerDurableAuthStorageChannel() { private func registerDurableAuthStorageChannel() {
guard let controller = window?.rootViewController as? FlutterViewController else { guard let controller = window?.rootViewController as? FlutterViewController else {
return return

View File

@ -0,0 +1,118 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/modules/room/background/room_background_history.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/room/rc_room_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_general_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
class RoomBackgroundApplyService {
static Future<String> apply(BuildContext context, String sourcePath) async {
final normalizedSource = sourcePath.trim();
if (normalizedSource.isEmpty) {
throw StateError("Room background source is empty");
}
final rtcProvider = context.read<RtcProvider>();
final rtmProvider = context.read<RtmProvider>();
final roomManager = context.read<SocialChatRoomManager>();
final roomProfile = rtcProvider.currenRoom?.roomProfile?.roomProfile;
final roomId = (roomProfile?.id ?? "").trim();
final roomAccount = (roomProfile?.roomAccount ?? "").trim();
if (roomId.isEmpty) {
throw StateError("Room id is empty");
}
final uploadedUrl =
_isNetworkImage(normalizedSource)
? normalizedSource
: await _uploadSource(normalizedSource);
if (uploadedUrl.trim().isEmpty) {
throw StateError("Uploaded background URL is empty");
}
debugPrint(
"[Room Background Apply] source=$normalizedSource uploadedUrl=$uploadedUrl",
);
final roomInfo = await SCAccountRepository().updateRoomBackground(
roomId,
uploadedUrl,
);
final roomBackground = _preferNonEmpty(
roomInfo.roomBackground,
uploadedUrl,
);
rtcProvider.updateCurrentRoomBasicInfo(roomBackground: roomBackground);
roomManager.updateMyRoomInfo(
roomInfo.copyWith(
id: roomInfo.id ?? roomId,
roomBackground: roomBackground,
),
);
if (roomAccount.isNotEmpty) {
rtmProvider.dispatchMessage(
Msg(
groupId: roomAccount,
msg: roomInfo.id ?? roomId,
type: SCRoomMsgType.roomSettingUpdate,
),
addLocal: false,
);
}
await RoomBackgroundHistory.add(roomBackground);
return roomBackground;
}
static bool _isNetworkImage(String value) {
return value.startsWith("http://") || value.startsWith("https://");
}
static Future<String> _uploadSource(String sourcePath) async {
final sourceFile =
_isAssetPath(sourcePath)
? await _copyAssetToTemporaryFile(sourcePath)
: File(sourcePath);
if (!sourceFile.existsSync()) {
throw StateError("Room background source file does not exist");
}
debugPrint(
"[Room Background Upload] upload local file path=${sourceFile.path} size=${sourceFile.lengthSync()}",
);
final uploadedUrl = await SCGeneralRepositoryImp().upload(sourceFile);
debugPrint("[Room Background Upload] upload success url=$uploadedUrl");
return uploadedUrl;
}
static bool _isAssetPath(String value) {
return value.startsWith("assets/") || value.startsWith("sc_images/");
}
static Future<File> _copyAssetToTemporaryFile(String assetPath) async {
final bytes = await rootBundle.load(assetPath);
final directory = await getTemporaryDirectory();
final fileName = assetPath.split("/").last;
final file = File("${directory.path}/room_background_$fileName");
await file.writeAsBytes(
bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes),
flush: true,
);
debugPrint(
"[Room Background Upload] copied asset=$assetPath to temp=${file.path} size=${file.lengthSync()}",
);
return file;
}
static String _preferNonEmpty(String? primary, String fallback) {
if ((primary ?? "").trim().isNotEmpty) {
return primary!.trim();
}
return fallback.trim();
}
}

View File

@ -0,0 +1,14 @@
const List<String> roomBackgroundExampleAssets = [
"sc_images/room/background_examples/bg_example_1.png",
"sc_images/room/background_examples/bg_example_2.png",
"sc_images/room/background_examples/bg_example_3.png",
"sc_images/room/background_examples/bg_example_4.png",
"sc_images/room/background_examples/bg_example_5.png",
"sc_images/room/background_examples/bg_example_6.png",
"sc_images/room/background_examples/bg_example_7.png",
"sc_images/room/background_examples/bg_example_8.png",
];
bool isRoomBackgroundExampleAsset(String value) {
return roomBackgroundExampleAssets.contains(value.trim());
}

View File

@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
class RoomBackgroundHistory {
static const int _maxCount = 30;
static const String _prefix = "room_background_history";
static String get _key {
final userId =
AccountStorage().getCurrentUser()?.userProfile?.id ?? "guest";
return "${_prefix}_$userId";
}
static List<String> load() {
final raw = DataPersistence.getString(_key);
if (raw.trim().isEmpty) {
return [];
}
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded
.map((item) => item?.toString().trim() ?? "")
.where((item) => item.isNotEmpty)
.toSet()
.toList();
}
} catch (_) {}
return [];
}
static Future<void> add(String imageUrl) async {
final normalized = imageUrl.trim();
if (normalized.isEmpty) {
return;
}
final next =
load()
..removeWhere((item) => item == normalized)
..insert(0, normalized);
if (next.length > _maxCount) {
next.removeRange(_maxCount, next.length);
}
await DataPersistence.setString(_key, jsonEncode(next));
}
}

View File

@ -2,10 +2,17 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart'; import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/room/background/room_background_apply_service.dart';
import 'package:yumi/modules/room/background/room_background_assets.dart';
import 'package:yumi/modules/room/background/room_background_history.dart';
import 'package:yumi/modules/room/voice_room_route.dart'; import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart'; import 'package:yumi/shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
@ -30,13 +37,32 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_handleTabChange); _tabController.addListener(_handleTabChange);
_officialItems = [ final currentBackground = _currentRoomBackground();
_RoomBackgroundItem(inUse: true), _officialItems =
_RoomBackgroundItem(), roomBackgroundExampleAssets
_RoomBackgroundItem(), .map(
_RoomBackgroundItem(), (path) => _RoomBackgroundItem(
]; imagePath: path,
_mineItems = [_RoomBackgroundItem(), _RoomBackgroundItem()]; inUse: path == currentBackground,
),
)
.toList();
final historyItems = RoomBackgroundHistory.load();
if (currentBackground.isNotEmpty &&
!isRoomBackgroundExampleAsset(currentBackground) &&
!historyItems.contains(currentBackground)) {
historyItems.insert(0, currentBackground);
RoomBackgroundHistory.add(currentBackground);
}
_mineItems =
historyItems
.map(
(path) => _RoomBackgroundItem(
imagePath: path,
inUse: path == currentBackground,
),
)
.toList();
} }
@override @override
@ -62,35 +88,83 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
} }
setState(() { setState(() {
for (final item in _mineItems) { _markInUse(uploadedPath!);
item.inUse = false; _upsertMineItem(uploadedPath, inUse: true);
}
_mineItems.insert(
0,
_RoomBackgroundItem(localImagePath: uploadedPath, inUse: true),
);
}); });
_tabController.animateTo(1); _tabController.animateTo(1);
} }
void _selectItem(List<_RoomBackgroundItem> items, int index) {
setState(() {
for (final item in items) {
item.inUse = false;
}
items[index].inUse = true;
});
}
Future<void> _previewItem(List<_RoomBackgroundItem> items, int index) async { Future<void> _previewItem(List<_RoomBackgroundItem> items, int index) async {
final shouldUse = await VoiceRoomRoute.openRoomBackgroundPreview<bool>( final shouldUse = await VoiceRoomRoute.openRoomBackgroundPreview<bool>(
context, context,
backgroundPath: items[index].localImagePath, backgroundPath: items[index].imagePath,
); );
if (!mounted || shouldUse != true) { if (!mounted || shouldUse != true) {
return; return;
} }
_selectItem(items, index); await _applyItem(items, index);
}
Future<void> _applyItem(List<_RoomBackgroundItem> items, int index) async {
final item = items[index];
final imagePath = item.imagePath.trim();
if (imagePath.isEmpty) {
return;
}
final localizations = SCAppLocalizations.of(context)!;
SCLoadingManager.show(context: context);
try {
final appliedUrl = await RoomBackgroundApplyService.apply(
context,
imagePath,
);
if (!mounted) {
return;
}
setState(() {
_markInUse(imagePath);
_upsertMineItem(appliedUrl, inUse: items == _mineItems);
});
SCTts.show(localizations.operationSuccessful);
} catch (e, stackTrace) {
debugPrint("[Room Background Select] apply failed error=$e");
debugPrint("[Room Background Select] stackTrace=$stackTrace");
if (mounted) {
SCTts.show(localizations.operationFail);
}
} finally {
SCLoadingManager.hide();
}
}
String _currentRoomBackground() {
return context
.read<RtcProvider>()
.currenRoom
?.roomProfile
?.roomProfile
?.roomBackground
?.trim() ??
"";
}
void _markInUse(String imagePath) {
final normalized = imagePath.trim();
for (final item in [..._officialItems, ..._mineItems]) {
item.inUse = item.imagePath == normalized;
}
}
void _upsertMineItem(String imagePath, {bool inUse = false}) {
final normalized = imagePath.trim();
if (normalized.isEmpty) {
return;
}
_mineItems.removeWhere((item) => item.imagePath == normalized);
_mineItems.insert(0, _RoomBackgroundItem(imagePath: normalized));
if (inUse) {
_markInUse(normalized);
}
} }
@override @override
@ -371,13 +445,32 @@ class _RoomBackgroundCard extends StatelessWidget {
Widget _buildPreview(BuildContext context) { Widget _buildPreview(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!; final localizations = SCAppLocalizations.of(context)!;
if ((item.localImagePath ?? "").isNotEmpty) { final imagePath = item.imagePath.trim();
final file = File(item.localImagePath!); if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return Image.network(
imagePath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildEmptyPreview(localizations),
);
}
if (imagePath.startsWith("assets/") || imagePath.startsWith("sc_images/")) {
return Image.asset(
imagePath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildEmptyPreview(localizations),
);
}
if (imagePath.isNotEmpty) {
final file = File(imagePath);
if (file.existsSync()) { if (file.existsSync()) {
return Image.file(file, fit: BoxFit.cover); return Image.file(file, fit: BoxFit.cover);
} }
} }
return _buildEmptyPreview(localizations);
}
Widget _buildEmptyPreview(SCAppLocalizations localizations) {
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@ -416,8 +509,8 @@ class _RoomBackgroundCard extends StatelessWidget {
} }
class _RoomBackgroundItem { class _RoomBackgroundItem {
_RoomBackgroundItem({this.localImagePath, this.inUse = false}); _RoomBackgroundItem({required this.imagePath, this.inUse = false});
final String? localImagePath; final String imagePath;
bool inUse; bool inUse;
} }

View File

@ -6,6 +6,10 @@ import 'package:image_picker/image_picker.dart';
import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart'; import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/room/background/room_background_apply_service.dart';
import 'package:yumi/modules/room/background/room_background_assets.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
@ -20,8 +24,12 @@ class RoomBackgroundUploadPage extends StatefulWidget {
class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> { class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
File? _selectedImage; File? _selectedImage;
bool _isSaving = false;
Future<void> _pickImage() async { Future<void> _pickImage() async {
if (_isSaving) {
return;
}
final pickedFile = await _picker.pickImage( final pickedFile = await _picker.pickImage(
source: ImageSource.gallery, source: ImageSource.gallery,
imageQuality: 90, imageQuality: 90,
@ -37,6 +45,42 @@ class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
}); });
} }
Future<void> _saveBackground() async {
final selectedImage = _selectedImage;
if (selectedImage == null || _isSaving) {
return;
}
final localizations = SCAppLocalizations.of(context)!;
setState(() {
_isSaving = true;
});
SCLoadingManager.show(context: context);
try {
final roomBackground = await RoomBackgroundApplyService.apply(
context,
selectedImage.path,
);
if (!mounted) {
return;
}
SCTts.show(localizations.operationSuccessful);
SCNavigatorUtils.goBackWithParams(context, roomBackground);
} catch (e, stackTrace) {
debugPrint("[Room Background Save] failed error=$e");
debugPrint("[Room Background Save] stackTrace=$stackTrace");
if (mounted) {
SCTts.show(localizations.operationFail);
}
} finally {
SCLoadingManager.hide();
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!; final localizations = SCAppLocalizations.of(context)!;
@ -125,12 +169,18 @@ class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
), ),
), ),
SizedBox(height: 16.w), SizedBox(height: 16.w),
Row( SizedBox(
children: [ height: 300.w,
Expanded(child: _RoomBackgroundExampleCard()), child: ListView.separated(
SizedBox(width: 16.w), scrollDirection: Axis.horizontal,
Expanded(child: _RoomBackgroundExampleCard()), padding: EdgeInsets.symmetric(horizontal: 28.w),
], itemCount: roomBackgroundExampleAssets.length,
separatorBuilder: (_, __) => SizedBox(width: 16.w),
itemBuilder:
(context, index) => _RoomBackgroundExampleCard(
assetPath: roomBackgroundExampleAssets[index],
),
),
), ),
SizedBox(height: 16.w), SizedBox(height: 16.w),
Text( Text(
@ -144,15 +194,9 @@ class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
SizedBox(height: 28.w), SizedBox(height: 28.w),
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap: _selectedImage == null ? null : _saveBackground,
_selectedImage == null
? null
: () => SCNavigatorUtils.goBackWithParams(
context,
_selectedImage!.path,
),
child: Opacity( child: Opacity(
opacity: _selectedImage == null ? 0.45 : 1, opacity: _selectedImage == null || _isSaving ? 0.45 : 1,
child: Container( child: Container(
height: 48.w, height: 48.w,
width: double.infinity, width: double.infinity,
@ -188,24 +232,36 @@ class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
} }
class _RoomBackgroundExampleCard extends StatelessWidget { class _RoomBackgroundExampleCard extends StatelessWidget {
const _RoomBackgroundExampleCard({required this.assetPath});
final String assetPath;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!; final localizations = SCAppLocalizations.of(context)!;
return Container( return SizedBox(
width: 140.w,
height: 300.w, height: 300.w,
decoration: BoxDecoration( child: ClipRRect(
borderRadius: BorderRadius.circular(14.w), borderRadius: BorderRadius.circular(14.w),
color: const Color(0xff143B34),
),
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Image.asset(
assetPath,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Container(
color: const Color(0xff143B34),
alignment: Alignment.center,
child: Icon( child: Icon(
Icons.image_outlined, Icons.image_outlined,
size: 42.w, size: 42.w,
color: Colors.white.withValues(alpha: 0.12), color: Colors.white.withValues(alpha: 0.12),
), ),
), ),
),
),
Positioned( Positioned(
left: 12.w, left: 12.w,
right: 12.w, right: 12.w,
@ -218,7 +274,7 @@ class _RoomBackgroundExampleCard extends StatelessWidget {
color: SocialChatTheme.primaryLight, color: SocialChatTheme.primaryLight,
width: 1.w, width: 1.w,
), ),
color: const Color(0xff1B4A40), color: const Color(0xff1B4A40).withValues(alpha: 0.82),
), ),
child: Text( child: Text(
localizations.staticOrGifImage, localizations.staticOrGifImage,
@ -233,6 +289,7 @@ class _RoomBackgroundExampleCard extends StatelessWidget {
), ),
], ],
), ),
),
); );
} }
} }

View File

@ -19,6 +19,7 @@ import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_lucky_gift_win_sound_player.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
@ -252,7 +253,12 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
} }
} }
String? _resolveRoomThemeBackground(JoinRoomRes? room) { String? _resolveRoomBackground(JoinRoomRes? room) {
final roomBackground =
room?.roomProfile?.roomProfile?.roomBackground?.trim() ?? "";
if (roomBackground.isNotEmpty) {
return roomBackground;
}
final roomTheme = room?.roomProps?.roomTheme; final roomTheme = room?.roomProps?.roomTheme;
final themeBack = roomTheme?.themeBack ?? ""; final themeBack = roomTheme?.themeBack ?? "";
if (themeBack.isEmpty) { if (themeBack.isEmpty) {
@ -291,11 +297,11 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
Selector<RtcProvider, String?>( Selector<RtcProvider, String?>(
selector: selector:
(context, provider) => (context, provider) =>
_resolveRoomThemeBackground(provider.currenRoom), _resolveRoomBackground(provider.currenRoom),
builder: (context, roomThemeBackground, child) { builder: (context, roomBackground, child) {
return roomThemeBackground != null return roomBackground != null
? netImage( ? netImage(
url: roomThemeBackground, url: roomBackground,
width: ScreenUtil().screenWidth, width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight, height: ScreenUtil().screenHeight,
noDefaultImg: true, noDefaultImg: true,
@ -327,7 +333,13 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
const RoomBottomWidget(showGiftComboButton: false), const RoomBottomWidget(showGiftComboButton: false),
], ],
), ),
LGiftAnimalPage(), PositionedDirectional(
start: 0,
end: 0,
top: 0,
bottom: RoomBottomWidget.floatingButtonHostHeight.w,
child: const IgnorePointer(child: LGiftAnimalPage()),
),
Transform.translate( Transform.translate(
offset: Offset(0, -20), offset: Offset(0, -20),
child: RoomAnimationQueueScreen(), child: RoomAnimationQueueScreen(),
@ -569,6 +581,16 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context, context,
listen: false, listen: false,
).enqueueGiftAnimation(giftModel); ).enqueueGiftAnimation(giftModel);
unawaited(
SCLuckyGiftWinSoundPlayer.play(
eventKey: SCLuckyGiftWinSoundPlayer.buildEventKey(
giftId: msg.gift?.id,
userId: msg.user?.id,
toUserId: msg.toUser?.id,
awardAmount: awardAmount,
),
),
);
} }
String _formatLuckyRewardAmount(num awardAmount) { String _formatLuckyRewardAmount(num awardAmount) {

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:fluro/fluro.dart'; import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/background/room_background_preview_page.dart'; import 'package:yumi/modules/room/background/room_background_preview_page.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart'; import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart'; import 'package:yumi/modules/room/background/room_background_upload_page.dart';
@ -11,6 +12,13 @@ import 'package:yumi/modules/room/music/room_music_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart'; import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart'; import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart'; import 'package:yumi/app/routes/sc_router_init.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/gift/gift_animation_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';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart';
class VoiceRoomRoute implements SCIRouterProvider { class VoiceRoomRoute implements SCIRouterProvider {
static String voiceRoom = '/room'; static String voiceRoom = '/room';
@ -51,6 +59,49 @@ class VoiceRoomRoute implements SCIRouterProvider {
); );
} }
static Future<T?> _pushBackgroundRoute<T>(
BuildContext context,
Route<T> route, {
bool rootNavigator = false,
}) {
final restoreEffects = _pauseRoomVisualEffects(context);
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(route).whenComplete(restoreEffects);
}
static VoidCallback _pauseRoomVisualEffects(BuildContext context) {
final rtcProvider = context.read<RtcProvider>();
final rtmProvider = context.read<RtmProvider>();
final giftAnimationManager = context.read<GiftAnimationManager>();
final wasEnabled = rtcProvider.roomVisualEffectsEnabled;
final floatingGiftListener = rtmProvider.msgFloatingGiftListener;
final luckyGiftRewardTickerListener =
rtmProvider.msgLuckyGiftRewardTickerListener;
rtcProvider.setRoomVisualEffectsEnabled(false);
rtmProvider.msgFloatingGiftListener = null;
rtmProvider.msgLuckyGiftRewardTickerListener = null;
RoomEntranceHelper.clearQueue();
giftAnimationManager.clearActiveAnimations();
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'room_background_route');
SCGiftVapSvgaManager().stopPlayback();
return () {
if (!context.mounted || rtcProvider.currenRoom == null) {
return;
}
rtcProvider.setRoomVisualEffectsEnabled(wasEnabled);
if (wasEnabled) {
rtmProvider.msgFloatingGiftListener ??= floatingGiftListener;
rtmProvider.msgLuckyGiftRewardTickerListener ??=
luckyGiftRewardTickerListener;
}
};
}
static Future<T?> openVoiceRoom<T>( static Future<T?> openVoiceRoom<T>(
BuildContext context, { BuildContext context, {
bool replace = false, bool replace = false,
@ -107,11 +158,13 @@ class VoiceRoomRoute implements SCIRouterProvider {
BuildContext context, { BuildContext context, {
bool rootNavigator = false, bool rootNavigator = false,
}) { }) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>( return _pushBackgroundRoute<T>(
context,
_buildDarkRoute<T>( _buildDarkRoute<T>(
const RoomBackgroundSelectPage(), const RoomBackgroundSelectPage(),
settings: RouteSettings(name: roomBackgroundSelect), settings: RouteSettings(name: roomBackgroundSelect),
), ),
rootNavigator: rootNavigator,
); );
} }
@ -119,11 +172,13 @@ class VoiceRoomRoute implements SCIRouterProvider {
BuildContext context, { BuildContext context, {
bool rootNavigator = false, bool rootNavigator = false,
}) { }) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>( return _pushBackgroundRoute<T>(
context,
_buildDarkRoute<T>( _buildDarkRoute<T>(
const RoomBackgroundUploadPage(), const RoomBackgroundUploadPage(),
settings: RouteSettings(name: roomBackgroundUpload), settings: RouteSettings(name: roomBackgroundUpload),
), ),
rootNavigator: rootNavigator,
); );
} }
@ -132,11 +187,13 @@ class VoiceRoomRoute implements SCIRouterProvider {
String? backgroundPath, String? backgroundPath,
bool rootNavigator = false, bool rootNavigator = false,
}) { }) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>( return _pushBackgroundRoute<T>(
context,
_buildDarkRoute<T>( _buildDarkRoute<T>(
RoomBackgroundPreviewPage(backgroundPath: backgroundPath), RoomBackgroundPreviewPage(backgroundPath: backgroundPath),
settings: RouteSettings(name: roomBackgroundPreview), settings: RouteSettings(name: roomBackgroundPreview),
), ),
rootNavigator: rootNavigator,
); );
} }

View File

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:carousel_slider/carousel_slider.dart'; import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter/services.dart';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/modules/user/profile/profile/sc_profile_page.dart'; import 'package:yumi/modules/user/profile/profile/sc_profile_page.dart';
@ -28,6 +29,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository
import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import '../../../app/constants/sc_screen.dart'; import '../../../app/constants/sc_screen.dart';
@ -157,7 +159,6 @@ class _PersonDetailPageState extends State<PersonDetailPage>
} }
Widget _buildTab(int index, String text) { Widget _buildTab(int index, String text) {
final isSelected = _tabController.index == index;
return Tab(text: text); return Tab(text: text);
} }
@ -168,6 +169,236 @@ class _PersonDetailPageState extends State<PersonDetailPage>
super.dispose(); super.dispose();
} }
Widget _buildHeaderAvatar(SocialChatUserProfileManager ref) {
if (ref.userProfile?.cpList?.isNotEmpty ?? false) {
return SizedBox(
width: double.infinity,
height: 100.w,
child: Center(
child: SizedBox(
width: 170.w,
height: 100.w,
child: Stack(
alignment: Alignment.center,
children: [
Transform.translate(
offset: Offset(0, -5.w),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
child: netImage(
url:
ref.userProfile?.cpList?.first.meUserAvatar ?? "",
defaultImg:
"sc_images/general/sc_icon_avar_defalt.png",
width: 56.w,
shape: BoxShape.circle,
),
onTap: () {
String encodedUrls = Uri.encodeComponent(
jsonEncode([
ref.userProfile?.cpList?.first.meUserAvatar,
]),
);
SCNavigatorUtils.push(
context,
"${SCMainRoute.imagePreview}?imageUrls=$encodedUrls&initialIndex=0",
);
},
),
SizedBox(width: 32.w),
GestureDetector(
onTap: () {
SCNavigatorUtils.push(
context,
replace: true,
"${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == ref.userProfile?.cpList?.first.cpUserId}&tageId=${ref.userProfile?.cpList?.first.cpUserId}",
);
},
child: netImage(
url:
ref.userProfile?.cpList?.first.cpUserAvatar ?? "",
defaultImg:
"sc_images/general/sc_icon_avar_defalt.png",
width: 56.w,
shape: BoxShape.circle,
),
),
],
),
),
IgnorePointer(
child: Transform.translate(
offset: Offset(0, -15.w),
child: Image.asset(
"sc_images/person/sc_icon_send_cp_requst_dialog_head.png",
),
),
),
],
),
),
),
);
}
return Center(
child: GestureDetector(
child: head(
url: ref.userProfile?.userAvatar ?? "",
width: 88.w,
headdress: ref.userProfile?.getHeaddress()?.sourceUrl,
),
onTap: () {
String encodedUrls = Uri.encodeComponent(
jsonEncode([ref.userProfile?.userAvatar]),
);
SCNavigatorUtils.push(
context,
"${SCMainRoute.imagePreview}?imageUrls=$encodedUrls&initialIndex=0",
);
},
),
);
}
Widget _buildHeaderName(SocialChatUserProfileManager ref) {
final nickname = ref.userProfile?.userNickname ?? "";
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
socialchatNickNameText(
nickname,
maxWidth: 240.w,
fontSize: 18.sp,
textColor: Colors.white,
fontWeight: FontWeight.w600,
type: ref.userProfile?.getVIP()?.name ?? "",
needScroll: nickname.characters.length > 18,
),
SizedBox(width: 3.w),
getVIPBadge(ref.userProfile?.getVIP()?.name, width: 45.w, height: 25.w),
],
);
}
Widget _buildHeaderMeta(SocialChatUserProfileManager ref) {
final idText = ref.userProfile?.getID() ?? "";
return Directionality(
textDirection: TextDirection.ltr,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Clipboard.setData(ClipboardData(text: idText));
SCTts.show(SCAppLocalizations.of(context)!.copiedToClipboard);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SCSpecialIdBadge(
idText: idText,
showAnimated: ref.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 80.w,
animationHeight: 32.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref.userProfile?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
animationFit: BoxFit.contain,
),
SizedBox(width: 6.w),
Image.asset(
"sc_images/room/sc_icon_user_card_copy_id.png",
width: 15.w,
height: 15.w,
),
],
),
),
SizedBox(width: 9.w),
_buildHeaderGenderAge(ref),
SizedBox(width: 8.w),
_buildHeaderCountryFlag(ref),
],
),
);
}
Widget _buildHeaderGenderAge(SocialChatUserProfileManager ref) {
return Container(
width: (ref.userProfile?.age ?? 0) > 999 ? 58.w : 48.w,
height: 24.w,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
ref.userProfile?.userSex == 0
? "sc_images/login/sc_icon_sex_woman_bg.png"
: "sc_images/login/sc_icon_sex_man_bg.png",
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
xb(ref.userProfile?.userSex),
text(
"${ref.userProfile?.age ?? 0}",
textColor: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
SizedBox(width: 3.w),
],
),
);
}
Widget _buildHeaderCountryFlag(SocialChatUserProfileManager ref) {
final countryName = ref.userProfile?.countryName ?? "";
String flag = "";
try {
flag =
Provider.of<SCAppGeneralManager>(
context,
listen: false,
).findCountryByName(countryName)?.nationalFlag ??
"";
} catch (_) {
flag = "";
}
if (flag.isEmpty) return const SizedBox.shrink();
return ClipRRect(
borderRadius: BorderRadius.circular(2.w),
child: netImage(
url: flag,
width: 24.w,
height: 16.w,
fit: BoxFit.cover,
noDefaultImg: true,
),
);
}
Widget _buildProfileHeader( Widget _buildProfileHeader(
SocialChatUserProfileManager ref, SocialChatUserProfileManager ref,
List<PersonPhoto> backgroundPhotos, List<PersonPhoto> backgroundPhotos,
@ -208,7 +439,7 @@ class _PersonDetailPageState extends State<PersonDetailPage>
}).toList(), }).toList(),
) )
: Image.asset( : Image.asset(
'sc_images/person/sc_icon_my_head_bg_defalt.png', 'sc_images/person/sc_icon_profile_card_default_bg.png',
width: ScreenUtil().screenWidth, width: ScreenUtil().screenWidth,
height: 300.w, height: 300.w,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -219,212 +450,20 @@ class _PersonDetailPageState extends State<PersonDetailPage>
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Container( child: Container(color: Color(0xff083b2f)),
decoration: BoxDecoration(
color: Color(0xff083b2f),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.w),
topRight: Radius.circular(8.w),
),
),
),
), ),
Positioned( Positioned(
top: 210.w, top: 210.w,
left: 0, left: 0,
right: 0, right: 0,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(width: 15.w),
(ref.userProfile?.cpList?.isNotEmpty ?? false)
? SizedBox(
height: 100.w,
child: Stack(
alignment: Alignment.center,
children: [
Transform.translate(
offset: Offset(0, -5),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
GestureDetector( _buildHeaderAvatar(ref),
child: netImage(
url:
ref
.userProfile
?.cpList
?.first
.meUserAvatar ??
"",
defaultImg:
"sc_images/general/sc_icon_avar_defalt.png",
width: 56.w,
shape: BoxShape.circle,
),
onTap: () {
String encodedUrls =
Uri.encodeComponent(
jsonEncode([
ref
.userProfile
?.cpList
?.first
.meUserAvatar,
]),
);
SCNavigatorUtils.push(
context,
"${SCMainRoute.imagePreview}?imageUrls=$encodedUrls&initialIndex=0",
);
},
),
SizedBox(width: 32.w),
GestureDetector(
onTap: () {
SCNavigatorUtils.push(
context,
replace: true,
"${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == ref.userProfile?.cpList?.first.cpUserId}&tageId=${ref.userProfile?.cpList?.first.cpUserId}",
);
},
child: netImage(
url:
ref
.userProfile
?.cpList
?.first
.cpUserAvatar ??
"",
defaultImg:
"sc_images/general/sc_icon_avar_defalt.png",
width: 56.w,
shape: BoxShape.circle,
),
),
],
),
),
IgnorePointer(
child: Transform.translate(
offset: Offset(0, -15),
child: Image.asset(
"sc_images/person/sc_icon_send_cp_requst_dialog_head.png",
),
),
),
],
),
)
: GestureDetector(
child: head(
url: ref.userProfile?.userAvatar ?? "",
width: 88.w,
headdress:
ref.userProfile?.getHeaddress()?.sourceUrl,
),
onTap: () {
String encodedUrls = Uri.encodeComponent(
jsonEncode([ref.userProfile?.userAvatar]),
);
SCNavigatorUtils.push(
context,
"${SCMainRoute.imagePreview}?imageUrls=$encodedUrls&initialIndex=0",
);
},
),
],
),
SizedBox(height: 6.w), SizedBox(height: 6.w),
Row( _buildHeaderName(ref),
children: [
SizedBox(width: 25.w),
socialchatNickNameText(
maxWidth: 135.w,
ref.userProfile?.userNickname ?? "",
fontSize: 18.sp,
textColor: Colors.white,
fontWeight: FontWeight.w600,
type: ref.userProfile?.getVIP()?.name ?? "",
needScroll:
(ref.userProfile?.userNickname?.characters.length ??
0) >
13,
),
SizedBox(width: 3.w),
getVIPBadge(
ref.userProfile?.getVIP()?.name,
width: 45.w,
height: 25.w,
),
Row(
children: [
SizedBox(width: 5.w),
Container(
width:
(ref.userProfile?.age ?? 0) > 999 ? 58.w : 48.w,
height: 24.w,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
ref.userProfile?.userSex == 0
? "sc_images/login/sc_icon_sex_woman_bg.png"
: "sc_images/login/sc_icon_sex_man_bg.png",
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
xb(ref.userProfile?.userSex),
text(
"${ref.userProfile?.age}",
textColor: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
SizedBox(width: 3.w),
],
),
),
],
),
],
),
SizedBox(height: 5.w), SizedBox(height: 5.w),
Row( _buildHeaderMeta(ref),
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(width: 25.w),
SCSpecialIdBadge(
idText: ref.userProfile?.getID() ?? "",
showAnimated: ref.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 80.w,
animationHeight: 32.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref.userProfile?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
animationFit: BoxFit.contain,
),
],
),
SizedBox(height: 5.w), SizedBox(height: 5.w),
Consumer<SocialChatUserProfileManager>( Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) { builder: (context, ref, child) {
@ -560,127 +599,6 @@ class _PersonDetailPageState extends State<PersonDetailPage>
); );
} }
Widget _buildSkeletonPiece({
double? width,
required double height,
BorderRadius? borderRadius,
BoxShape shape = BoxShape.rectangle,
}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.14),
borderRadius:
shape == BoxShape.circle
? null
: (borderRadius ?? BorderRadius.circular(8.w)),
shape: shape,
),
);
}
Widget _buildProfileSkeleton() {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 210.w),
Padding(
padding: EdgeInsets.only(left: 15.w),
child: _buildSkeletonPiece(
width: 88.w,
height: 88.w,
shape: BoxShape.circle,
),
),
SizedBox(height: 10.w),
Padding(
padding: EdgeInsets.only(left: 25.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSkeletonPiece(width: 150.w, height: 24.w),
SizedBox(height: 10.w),
_buildSkeletonPiece(width: 110.w, height: 18.w),
],
),
),
SizedBox(height: 12.w),
Container(
height: 72.w,
alignment: AlignmentDirectional.center,
width: ScreenUtil().screenWidth,
margin: EdgeInsets.symmetric(horizontal: 18.w),
padding: EdgeInsets.symmetric(horizontal: 35.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
3,
(_) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSkeletonPiece(width: 24.w, height: 22.w),
SizedBox(height: 8.w),
_buildSkeletonPiece(width: 58.w, height: 14.w),
],
),
),
),
),
SizedBox(height: 6.w),
Container(
padding: EdgeInsets.symmetric(horizontal: 3.w),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xff083b2f), Color(0xff083b2f)],
begin: AlignmentDirectional.topStart,
end: AlignmentDirectional.bottomEnd,
),
),
child: Column(
children: [
SizedBox(height: 10.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Row(
children: [
_buildSkeletonPiece(width: 92.w, height: 18.w),
SizedBox(width: 14.w),
_buildSkeletonPiece(width: 72.w, height: 18.w),
],
),
),
SizedBox(height: 24.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 18.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSkeletonPiece(width: 100.w, height: 22.w),
SizedBox(height: 18.w),
_buildSkeletonPiece(width: 220.w, height: 16.w),
SizedBox(height: 14.w),
_buildSkeletonPiece(width: 190.w, height: 16.w),
SizedBox(height: 14.w),
_buildSkeletonPiece(width: 240.w, height: 16.w),
SizedBox(height: 14.w),
_buildSkeletonPiece(width: 200.w, height: 16.w),
SizedBox(height: 14.w),
_buildSkeletonPiece(width: 160.w, height: 16.w),
],
),
),
SizedBox(height: 220.w),
],
),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_tabs.clear(); _tabs.clear();
@ -722,7 +640,7 @@ class _PersonDetailPageState extends State<PersonDetailPage>
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Image.asset( : Image.asset(
'sc_images/person/sc_icon_my_head_bg_defalt.png', 'sc_images/person/sc_icon_profile_card_default_bg.png',
width: ScreenUtil().screenWidth, width: ScreenUtil().screenWidth,
height: 300.w, height: 300.w,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -1063,7 +981,7 @@ class _PersonDetailPageState extends State<PersonDetailPage>
isBlacklist isBlacklist
? Container() ? Container()
: (isProfileLoading || isBlacklistLoading : (isProfileLoading || isBlacklistLoading
? _buildProfileSkeleton() ? const SizedBox.shrink()
: ExtendedNestedScrollView( : ExtendedNestedScrollView(
controller: _scrollController, controller: _scrollController,
onlyOneScrollInBody: true, onlyOneScrollInBody: true,

View File

@ -517,7 +517,9 @@ class _VipDetailPageState extends State<VipDetailPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox(height: 4.w), SizedBox(height: 4.w),
Text( Directionality(
textDirection: TextDirection.ltr,
child: Text(
config?.displayNameText ?? 'VIP$level', config?.displayNameText ?? 'VIP$level',
style: TextStyle( style: TextStyle(
color: color:
@ -530,6 +532,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
),
SizedBox(height: 6.w), SizedBox(height: 6.w),
SizedBox( SizedBox(
width: 0, width: 0,
@ -1147,7 +1150,9 @@ class _VipDetailPageState extends State<VipDetailPage> {
), ),
), ),
SizedBox(width: 6.w), SizedBox(width: 6.w),
Text( Directionality(
textDirection: TextDirection.ltr,
child: Text(
_priceText, _priceText,
style: TextStyle( style: TextStyle(
color: const Color(0xFFFFE8A7), color: const Color(0xFFFFE8A7),
@ -1155,6 +1160,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
),
const Spacer(), const Spacer(),
SCDebounceWidget( SCDebounceWidget(
onTap: canPurchase ? _purchaseSelectedLevel : () {}, onTap: canPurchase ? _purchaseSelectedLevel : () {},
@ -1208,6 +1214,8 @@ class _VipDetailPageState extends State<VipDetailPage> {
return Transform.scale( return Transform.scale(
scale: scale, scale: scale,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Directionality(
textDirection: TextDirection.ltr,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@ -1234,6 +1242,7 @@ class _VipDetailPageState extends State<VipDetailPage> {
_buildVipLevelNumber(level), _buildVipLevelNumber(level),
], ],
), ),
),
); );
} }
@ -1362,7 +1371,8 @@ class _VipDetailPageState extends State<VipDetailPage> {
preview?.payableGold ?? config?.payableGold ?? config?.priceGold; preview?.payableGold ?? config?.payableGold ?? config?.priceGold;
final days = (preview?.durationDays ?? config?.durationDays ?? 30).toInt(); final days = (preview?.durationDays ?? config?.durationDays ?? 30).toInt();
final amountText = amount == null ? '--' : _formatGold(amount); final amountText = amount == null ? '--' : _formatGold(amount);
return '$amountText / $days Days'; final daysLabel = SCAppLocalizations.of(context)?.days ?? 'Days';
return '$amountText / $days $daysLabel';
} }
String _formatGold(num value) { String _formatGold(num value) {

View File

@ -99,6 +99,7 @@ class RoomEntryPreviewData {
this.roomAccount, this.roomAccount,
this.userId, this.userId,
this.roomCover, this.roomCover,
this.roomBackground,
this.roomName, this.roomName,
this.roomDesc, this.roomDesc,
this.event, this.event,
@ -124,6 +125,7 @@ class RoomEntryPreviewData {
roomAccount: room.roomAccount, roomAccount: room.roomAccount,
userId: room.userId, userId: room.userId,
roomCover: room.roomCover, roomCover: room.roomCover,
roomBackground: room.roomBackground,
roomName: room.roomName, roomName: room.roomName,
roomDesc: room.roomDesc, roomDesc: room.roomDesc,
event: room.event, event: room.event,
@ -145,6 +147,7 @@ class RoomEntryPreviewData {
roomAccount: roomProfile?.roomAccount, roomAccount: roomProfile?.roomAccount,
userId: roomProfile?.userId, userId: roomProfile?.userId,
roomCover: roomProfile?.roomCover, roomCover: roomProfile?.roomCover,
roomBackground: roomProfile?.roomBackground,
roomName: roomProfile?.roomName, roomName: roomProfile?.roomName,
roomDesc: roomProfile?.roomDesc, roomDesc: roomProfile?.roomDesc,
event: roomProfile?.event, event: roomProfile?.event,
@ -168,6 +171,7 @@ class RoomEntryPreviewData {
roomAccount: room.roomAccount, roomAccount: room.roomAccount,
userId: room.userId, userId: room.userId,
roomCover: room.roomCover, roomCover: room.roomCover,
roomBackground: room.roomBackground,
roomName: room.roomName, roomName: room.roomName,
roomDesc: room.roomDesc, roomDesc: room.roomDesc,
event: room.event, event: room.event,
@ -192,6 +196,7 @@ class RoomEntryPreviewData {
final String? roomAccount; final String? roomAccount;
final String? userId; final String? userId;
final String? roomCover; final String? roomCover;
final String? roomBackground;
final String? roomName; final String? roomName;
final String? roomDesc; final String? roomDesc;
final String? event; final String? event;
@ -241,6 +246,7 @@ class RoomEntryPreviewData {
nationalFlag: nationalFlag, nationalFlag: nationalFlag,
roomAccount: roomAccount, roomAccount: roomAccount,
roomCover: roomCover, roomCover: roomCover,
roomBackground: roomBackground,
roomDesc: roomDesc, roomDesc: roomDesc,
roomName: roomName, roomName: roomName,
sysOrigin: sysOrigin, sysOrigin: sysOrigin,
@ -1964,6 +1970,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void updateCurrentRoomBasicInfo({ void updateCurrentRoomBasicInfo({
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
}) { }) {
@ -1975,6 +1982,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
roomProfile: currenRoom?.roomProfile?.copyWith( roomProfile: currenRoom?.roomProfile?.copyWith(
roomProfile: currentRoomProfile.copyWith( roomProfile: currentRoomProfile.copyWith(
roomCover: _preferNonEmpty(roomCover, currentRoomProfile.roomCover), roomCover: _preferNonEmpty(roomCover, currentRoomProfile.roomCover),
roomBackground: _preferNonEmpty(
roomBackground,
currentRoomProfile.roomBackground,
),
roomName: _preferNonEmpty(roomName, currentRoomProfile.roomName), roomName: _preferNonEmpty(roomName, currentRoomProfile.roomName),
roomDesc: _preferNonEmpty(roomDesc, currentRoomProfile.roomDesc), roomDesc: _preferNonEmpty(roomDesc, currentRoomProfile.roomDesc),
), ),
@ -1983,6 +1994,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
SCRoomProfileCache.saveRoomProfile( SCRoomProfileCache.saveRoomProfile(
roomId: currentRoomProfile.id ?? "", roomId: currentRoomProfile.id ?? "",
roomCover: roomCover, roomCover: roomCover,
roomBackground: roomBackground,
roomName: roomName, roomName: roomName,
roomDesc: roomDesc, roomDesc: roomDesc,
); );
@ -3601,7 +3613,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final value = await SCChatRoomRepository().specific(roomId); final value = await SCChatRoomRepository().specific(roomId);
final currentRoomProfile = currenRoom?.roomProfile?.roomProfile; final currentRoomProfile = currenRoom?.roomProfile?.roomProfile;
debugPrint( debugPrint(
"[Room Cover Sync] loadRoomInfo fetched roomId=${value.id ?? roomId} roomCover=${value.roomCover ?? ""} roomName=${value.roomName ?? ""}", "[Room Cover Sync] loadRoomInfo fetched roomId=${value.id ?? roomId} roomCover=${value.roomCover ?? ""} roomBackground=${value.roomBackground ?? ""} roomName=${value.roomName ?? ""}",
); );
currenRoom = currenRoom?.copyWith( currenRoom = currenRoom?.copyWith(
roomProfile: currenRoom?.roomProfile?.copyWith( roomProfile: currenRoom?.roomProfile?.copyWith(
@ -3612,6 +3624,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
value.roomCover, value.roomCover,
currentRoomProfile.roomCover, currentRoomProfile.roomCover,
), ),
roomBackground: _preferNonEmpty(
value.roomBackground,
currentRoomProfile.roomBackground,
),
roomName: _preferNonEmpty( roomName: _preferNonEmpty(
value.roomName, value.roomName,
currentRoomProfile.roomName, currentRoomProfile.roomName,
@ -3624,7 +3640,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
), ),
); );
debugPrint( debugPrint(
"[Room Cover Sync] loadRoomInfo applied roomId=$roomId roomCover=${currenRoom?.roomProfile?.roomProfile?.roomCover ?? ""}", "[Room Cover Sync] loadRoomInfo applied roomId=$roomId roomCover=${currenRoom?.roomProfile?.roomProfile?.roomCover ?? ""} roomBackground=${currenRoom?.roomProfile?.roomProfile?.roomBackground ?? ""}",
); );
/// ///

View File

@ -6,6 +6,7 @@ import 'package:flutter/cupertino.dart';
import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart'; import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart';
class GiftAnimationManager extends ChangeNotifier { class GiftAnimationManager extends ChangeNotifier {
static const int maxVisibleSlots = 4;
static const int _maxPendingAnimations = 24; static const int _maxPendingAnimations = 24;
static const Duration _activeComboIdleDismissDelay = Duration( static const Duration _activeComboIdleDismissDelay = Duration(
milliseconds: 3200, milliseconds: 3200,
@ -17,10 +18,16 @@ class GiftAnimationManager extends ChangeNotifier {
// //
Map<int, LGiftModel?> giftMap = {0: null, 1: null, 2: null, 3: null}; Map<int, LGiftModel?> giftMap = {0: null, 1: null, 2: null, 3: null};
int _visibleSlotCount = 0;
bool get _controllersReady => bool get _controllersReady =>
animationControllerList.length >= giftMap.length; _visibleSlotCount <= 0 ||
animationControllerList.length >= _visibleSlotCount;
GiftAnimationSlotSnapshot? slotSnapshotAt(int index) { GiftAnimationSlotSnapshot? slotSnapshotAt(int index) {
if (index < 0 || index >= _visibleSlotCount) {
return null;
}
final gift = giftMap[index]; final gift = giftMap[index];
if (gift == null || animationControllerList.length <= index) { if (gift == null || animationControllerList.length <= index) {
return null; return null;
@ -43,6 +50,31 @@ class GiftAnimationManager extends ChangeNotifier {
); );
} }
void setVisibleSlotCount(int count) {
final nextCount = count.clamp(0, maxVisibleSlots).toInt();
if (nextCount == _visibleSlotCount) {
return;
}
final oldCount = _visibleSlotCount;
if (nextCount < oldCount) {
for (var index = oldCount - 1; index >= nextCount; index -= 1) {
final gift = giftMap[index];
if (gift == null) {
continue;
}
_cancelSlotDismissTimer(index);
giftMap[index] = null;
pendingAnimationsQueue.addFirst(gift);
}
_trimPendingAnimationsFromEnd();
}
_visibleSlotCount = nextCount;
notifyListeners();
proceedToNextAnimation();
}
void enqueueGiftAnimation(LGiftModel giftModel) { void enqueueGiftAnimation(LGiftModel giftModel) {
if (_mergeIntoActiveAnimation(giftModel)) { if (_mergeIntoActiveAnimation(giftModel)) {
return; return;
@ -55,21 +87,42 @@ class GiftAnimationManager extends ChangeNotifier {
proceedToNextAnimation(); proceedToNextAnimation();
} }
void clearActiveAnimations() {
pendingAnimationsQueue.clear();
var changed = false;
for (final index in giftMap.keys.toList()) {
_cancelSlotDismissTimer(index);
if (giftMap[index] != null) {
giftMap[index] = null;
changed = true;
}
}
if (changed) {
notifyListeners();
}
}
void _trimPendingAnimations() { void _trimPendingAnimations() {
while (pendingAnimationsQueue.length >= _maxPendingAnimations) { while (pendingAnimationsQueue.length >= _maxPendingAnimations) {
pendingAnimationsQueue.removeFirst(); pendingAnimationsQueue.removeFirst();
} }
} }
void _trimPendingAnimationsFromEnd() {
while (pendingAnimationsQueue.length > _maxPendingAnimations) {
pendingAnimationsQueue.removeLast();
}
}
bool _mergeIntoActiveAnimation(LGiftModel incoming) { bool _mergeIntoActiveAnimation(LGiftModel incoming) {
for (final entry in giftMap.entries) { for (var index = 0; index < _visibleSlotCount; index += 1) {
final current = entry.value; final current = giftMap[index];
if (current == null || current.labelId != incoming.labelId) { if (current == null || current.labelId != incoming.labelId) {
continue; continue;
} }
_mergeGiftModel(target: current, incoming: incoming); _mergeGiftModel(target: current, incoming: incoming);
notifyListeners(); notifyListeners();
_refreshSlotAnimation(entry.key, restartEntry: false); _refreshSlotAnimation(index, restartEntry: false);
return true; return true;
} }
return false; return false;
@ -119,41 +172,60 @@ class GiftAnimationManager extends ChangeNotifier {
/// ///
proceedToNextAnimation() { proceedToNextAnimation() {
if (pendingAnimationsQueue.isEmpty || !_controllersReady) { if (pendingAnimationsQueue.isEmpty ||
!_controllersReady ||
_visibleSlotCount <= 0) {
return; return;
} }
var playGift = pendingAnimationsQueue.first;
for (var key in giftMap.keys) { var changed = false;
var value = giftMap[key]; while (pendingAnimationsQueue.isNotEmpty) {
final playGift = pendingAnimationsQueue.first;
var consumed = false;
for (var index = 0; index < _visibleSlotCount; index += 1) {
final value = giftMap[index];
if (value == null) { if (value == null) {
giftMap[key] = playGift; giftMap[index] = playGift;
pendingAnimationsQueue.removeFirst(); pendingAnimationsQueue.removeFirst();
notifyListeners(); changed = true;
_refreshSlotAnimation(key, restartEntry: true); consumed = true;
_refreshSlotAnimation(index, restartEntry: true);
break; break;
} else { }
if (value.labelId == playGift.labelId) { if (value.labelId == playGift.labelId) {
_mergeGiftModel(target: value, incoming: playGift); _mergeGiftModel(target: value, incoming: playGift);
pendingAnimationsQueue.removeFirst(); pendingAnimationsQueue.removeFirst();
notifyListeners(); changed = true;
_refreshSlotAnimation(key, restartEntry: false); consumed = true;
_refreshSlotAnimation(index, restartEntry: false);
break; break;
} }
} }
if (!consumed) {
break;
}
}
if (changed) {
notifyListeners();
} }
} }
void attachAnimationControllers(List<LGiftScrollingScreenAnimsBean> anins) { void attachAnimationControllers(List<LGiftScrollingScreenAnimsBean> anins) {
animationControllerList = anins; animationControllerList = anins;
if (_visibleSlotCount > animationControllerList.length) {
setVisibleSlotCount(animationControllerList.length);
return;
}
proceedToNextAnimation(); proceedToNextAnimation();
} }
void cleanupAnimationResources() { void cleanupAnimationResources() {
pendingAnimationsQueue.clear(); pendingAnimationsQueue.clear();
giftMap[0] = null; for (final key in giftMap.keys) {
giftMap[1] = null; giftMap[key] = null;
giftMap[2] = null; }
giftMap[3] = null; _visibleSlotCount = 0;
for (var element in animationControllerList) { for (var element in animationControllerList) {
element.dismissTimer?.cancel(); element.dismissTimer?.cancel();
element.controller.dispose(); element.controller.dispose();

View File

@ -43,6 +43,9 @@ class SocialChatRoomManager extends ChangeNotifier {
if ((roomInfo.roomCover ?? "").trim().isNotEmpty) { if ((roomInfo.roomCover ?? "").trim().isNotEmpty) {
myRoom?.setRoomCover = roomInfo.roomCover!; myRoom?.setRoomCover = roomInfo.roomCover!;
} }
if ((roomInfo.roomBackground ?? "").trim().isNotEmpty) {
myRoom?.setRoomBackground = roomInfo.roomBackground!;
}
if (roomInfo.roomName != null) { if (roomInfo.roomName != null) {
myRoom?.setRoomName = roomInfo.roomName!; myRoom?.setRoomName = roomInfo.roomName!;
} }

View File

@ -65,6 +65,7 @@ class RoomProfile {
String? roomAccount, String? roomAccount,
List<String>? roomBadgeIcons, List<String>? roomBadgeIcons,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomGameIcon, String? roomGameIcon,
String? roomName, String? roomName,
@ -85,6 +86,7 @@ class RoomProfile {
_roomAccount = roomAccount; _roomAccount = roomAccount;
_roomBadgeIcons = roomBadgeIcons; _roomBadgeIcons = roomBadgeIcons;
_roomCover = roomCover; _roomCover = roomCover;
_roomBackground = roomBackground;
_roomDesc = roomDesc; _roomDesc = roomDesc;
_roomGameIcon = roomGameIcon; _roomGameIcon = roomGameIcon;
_roomName = roomName; _roomName = roomName;
@ -114,6 +116,7 @@ class RoomProfile {
? json['roomBadgeIcons'].cast<String>() ? json['roomBadgeIcons'].cast<String>()
: []; : [];
_roomCover = json['roomCover'] ?? json['cover']; _roomCover = json['roomCover'] ?? json['cover'];
_roomBackground = json['roomBackground'];
_roomDesc = json['roomDesc']; _roomDesc = json['roomDesc'];
_roomGameIcon = json['roomGameIcon']; _roomGameIcon = json['roomGameIcon'];
_roomName = json['roomName']; _roomName = json['roomName'];
@ -146,6 +149,7 @@ class RoomProfile {
String? _roomAccount; String? _roomAccount;
List<String>? _roomBadgeIcons; List<String>? _roomBadgeIcons;
String? _roomCover; String? _roomCover;
String? _roomBackground;
String? _roomDesc; String? _roomDesc;
String? _roomGameIcon; String? _roomGameIcon;
String? _roomName; String? _roomName;
@ -166,6 +170,7 @@ class RoomProfile {
String? roomAccount, String? roomAccount,
List<String>? roomBadgeIcons, List<String>? roomBadgeIcons,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomGameIcon, String? roomGameIcon,
String? roomName, String? roomName,
@ -186,6 +191,7 @@ class RoomProfile {
roomAccount: roomAccount ?? _roomAccount, roomAccount: roomAccount ?? _roomAccount,
roomBadgeIcons: roomBadgeIcons ?? _roomBadgeIcons, roomBadgeIcons: roomBadgeIcons ?? _roomBadgeIcons,
roomCover: roomCover ?? _roomCover, roomCover: roomCover ?? _roomCover,
roomBackground: roomBackground ?? _roomBackground,
roomDesc: roomDesc ?? _roomDesc, roomDesc: roomDesc ?? _roomDesc,
roomGameIcon: roomGameIcon ?? _roomGameIcon, roomGameIcon: roomGameIcon ?? _roomGameIcon,
roomName: roomName ?? _roomName, roomName: roomName ?? _roomName,
@ -206,6 +212,7 @@ class RoomProfile {
String? get roomAccount => _roomAccount; String? get roomAccount => _roomAccount;
List<String>? get roomBadgeIcons => _roomBadgeIcons; List<String>? get roomBadgeIcons => _roomBadgeIcons;
String? get roomCover => _roomCover; String? get roomCover => _roomCover;
String? get roomBackground => _roomBackground;
String? get roomDesc => _roomDesc; String? get roomDesc => _roomDesc;
String? get roomGameIcon => _roomGameIcon; String? get roomGameIcon => _roomGameIcon;
String? get roomName => _roomName; String? get roomName => _roomName;
@ -243,6 +250,7 @@ class RoomProfile {
map['roomAccount'] = _roomAccount; map['roomAccount'] = _roomAccount;
map['roomBadgeIcons'] = _roomBadgeIcons; map['roomBadgeIcons'] = _roomBadgeIcons;
map['roomCover'] = _roomCover; map['roomCover'] = _roomCover;
map['roomBackground'] = _roomBackground;
map['roomDesc'] = _roomDesc; map['roomDesc'] = _roomDesc;
map['roomGameIcon'] = _roomGameIcon; map['roomGameIcon'] = _roomGameIcon;
map['roomName'] = _roomName; map['roomName'] = _roomName;

View File

@ -716,6 +716,7 @@ class RoomProfile2 {
String? nationalFlag, String? nationalFlag,
String? roomAccount, String? roomAccount,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomName, String? roomName,
String? sysOrigin, String? sysOrigin,
@ -733,6 +734,7 @@ class RoomProfile2 {
_nationalFlag = nationalFlag; _nationalFlag = nationalFlag;
_roomAccount = roomAccount; _roomAccount = roomAccount;
_roomCover = roomCover; _roomCover = roomCover;
_roomBackground = roomBackground;
_roomDesc = roomDesc; _roomDesc = roomDesc;
_roomName = roomName; _roomName = roomName;
_sysOrigin = sysOrigin; _sysOrigin = sysOrigin;
@ -752,6 +754,7 @@ class RoomProfile2 {
_nationalFlag = json['nationalFlag']; _nationalFlag = json['nationalFlag'];
_roomAccount = json['roomAccount']; _roomAccount = json['roomAccount'];
_roomCover = json['roomCover'] ?? json['cover']; _roomCover = json['roomCover'] ?? json['cover'];
_roomBackground = json['roomBackground'];
_roomDesc = json['roomDesc']; _roomDesc = json['roomDesc'];
_roomName = json['roomName']; _roomName = json['roomName'];
_sysOrigin = json['sysOrigin']; _sysOrigin = json['sysOrigin'];
@ -770,6 +773,7 @@ class RoomProfile2 {
String? _nationalFlag; String? _nationalFlag;
String? _roomAccount; String? _roomAccount;
String? _roomCover; String? _roomCover;
String? _roomBackground;
String? _roomDesc; String? _roomDesc;
String? _roomName; String? _roomName;
String? _sysOrigin; String? _sysOrigin;
@ -788,6 +792,7 @@ class RoomProfile2 {
String? nationalFlag, String? nationalFlag,
String? roomAccount, String? roomAccount,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomName, String? roomName,
String? sysOrigin, String? sysOrigin,
@ -805,6 +810,7 @@ class RoomProfile2 {
nationalFlag: nationalFlag ?? _nationalFlag, nationalFlag: nationalFlag ?? _nationalFlag,
roomAccount: roomAccount ?? _roomAccount, roomAccount: roomAccount ?? _roomAccount,
roomCover: roomCover ?? _roomCover, roomCover: roomCover ?? _roomCover,
roomBackground: roomBackground ?? _roomBackground,
roomDesc: roomDesc ?? _roomDesc, roomDesc: roomDesc ?? _roomDesc,
roomName: roomName ?? _roomName, roomName: roomName ?? _roomName,
sysOrigin: sysOrigin ?? _sysOrigin, sysOrigin: sysOrigin ?? _sysOrigin,
@ -833,6 +839,7 @@ class RoomProfile2 {
String? get roomAccount => _roomAccount; String? get roomAccount => _roomAccount;
String? get roomCover => _roomCover; String? get roomCover => _roomCover;
String? get roomBackground => _roomBackground;
String? get roomDesc => _roomDesc; String? get roomDesc => _roomDesc;
@ -857,6 +864,7 @@ class RoomProfile2 {
map['nationalFlag'] = _nationalFlag; map['nationalFlag'] = _nationalFlag;
map['roomAccount'] = _roomAccount; map['roomAccount'] = _roomAccount;
map['roomCover'] = _roomCover; map['roomCover'] = _roomCover;
map['roomBackground'] = _roomBackground;
map['roomDesc'] = _roomDesc; map['roomDesc'] = _roomDesc;
map['roomName'] = _roomName; map['roomName'] = _roomName;
map['sysOrigin'] = _sysOrigin; map['sysOrigin'] = _sysOrigin;

View File

@ -29,6 +29,7 @@ class MyRoomRes {
String? roomAccount, String? roomAccount,
String? userId, String? userId,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
String? event, String? event,
@ -49,6 +50,7 @@ class MyRoomRes {
_roomAccount = roomAccount; _roomAccount = roomAccount;
_userId = userId; _userId = userId;
_roomCover = roomCover; _roomCover = roomCover;
_roomBackground = roomBackground;
_roomName = roomName; _roomName = roomName;
_roomDesc = roomDesc; _roomDesc = roomDesc;
_event = event; _event = event;
@ -71,6 +73,7 @@ class MyRoomRes {
_roomAccount = json['roomAccount']; _roomAccount = json['roomAccount'];
_userId = json['userId']; _userId = json['userId'];
_roomCover = json['roomCover'] ?? json['cover']; _roomCover = json['roomCover'] ?? json['cover'];
_roomBackground = json['roomBackground'];
_roomName = json['roomName']; _roomName = json['roomName'];
_roomDesc = json['roomDesc']; _roomDesc = json['roomDesc'];
_event = json['event']; _event = json['event'];
@ -98,6 +101,7 @@ class MyRoomRes {
String? _roomAccount; String? _roomAccount;
String? _userId; String? _userId;
String? _roomCover; String? _roomCover;
String? _roomBackground;
String? _roomName; String? _roomName;
String? _roomDesc; String? _roomDesc;
String? _event; String? _event;
@ -118,6 +122,7 @@ class MyRoomRes {
String? roomAccount, String? roomAccount,
String? userId, String? userId,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
String? event, String? event,
@ -138,6 +143,7 @@ class MyRoomRes {
roomAccount: roomAccount ?? _roomAccount, roomAccount: roomAccount ?? _roomAccount,
userId: userId ?? _userId, userId: userId ?? _userId,
roomCover: roomCover ?? _roomCover, roomCover: roomCover ?? _roomCover,
roomBackground: roomBackground ?? _roomBackground,
roomName: roomName ?? _roomName, roomName: roomName ?? _roomName,
roomDesc: roomDesc ?? _roomDesc, roomDesc: roomDesc ?? _roomDesc,
event: event ?? _event, event: event ?? _event,
@ -158,6 +164,7 @@ class MyRoomRes {
String? get roomAccount => _roomAccount; String? get roomAccount => _roomAccount;
String? get userId => _userId; String? get userId => _userId;
String? get roomCover => _roomCover; String? get roomCover => _roomCover;
String? get roomBackground => _roomBackground;
String? get roomName => _roomName; String? get roomName => _roomName;
String? get roomDesc => _roomDesc; String? get roomDesc => _roomDesc;
String? get event => _event; String? get event => _event;
@ -174,6 +181,7 @@ class MyRoomRes {
RoomCounter? get counter => _counter; RoomCounter? get counter => _counter;
List<UseBadges>? get useBadges => _useBadges; List<UseBadges>? get useBadges => _useBadges;
set setRoomCover(String value) => _roomCover = value; set setRoomCover(String value) => _roomCover = value;
set setRoomBackground(String value) => _roomBackground = value;
set setRoomName(String value) => _roomName = value; set setRoomName(String value) => _roomName = value;
set setRoomDesc(String value) => _roomDesc = value; set setRoomDesc(String value) => _roomDesc = value;
@ -183,6 +191,7 @@ class MyRoomRes {
map['roomAccount'] = _roomAccount; map['roomAccount'] = _roomAccount;
map['userId'] = _userId; map['userId'] = _userId;
map['roomCover'] = _roomCover; map['roomCover'] = _roomCover;
map['roomBackground'] = _roomBackground;
map['roomName'] = _roomName; map['roomName'] = _roomName;
map['roomDesc'] = _roomDesc; map['roomDesc'] = _roomDesc;
map['event'] = _event; map['event'] = _event;

View File

@ -30,6 +30,7 @@ class SocialChatRoomRes {
String? roomAccount, String? roomAccount,
List<String>? roomBadgeIcons, List<String>? roomBadgeIcons,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomGameIcon, String? roomGameIcon,
String? roomName, String? roomName,
@ -50,6 +51,7 @@ class SocialChatRoomRes {
_roomAccount = roomAccount; _roomAccount = roomAccount;
_roomBadgeIcons = roomBadgeIcons; _roomBadgeIcons = roomBadgeIcons;
_roomCover = roomCover; _roomCover = roomCover;
_roomBackground = roomBackground;
_roomDesc = roomDesc; _roomDesc = roomDesc;
_roomGameIcon = roomGameIcon; _roomGameIcon = roomGameIcon;
_roomName = roomName; _roomName = roomName;
@ -81,6 +83,7 @@ class SocialChatRoomRes {
.toList() .toList()
: null; : null;
_roomCover = json['roomCover'] ?? json['cover']; _roomCover = json['roomCover'] ?? json['cover'];
_roomBackground = json['roomBackground'];
_roomDesc = json['roomDesc']; _roomDesc = json['roomDesc'];
_roomGameIcon = json['roomGameIcon']; _roomGameIcon = json['roomGameIcon'];
_roomName = json['roomName']; _roomName = json['roomName'];
@ -114,6 +117,7 @@ class SocialChatRoomRes {
String? _roomAccount; String? _roomAccount;
List<String>? _roomBadgeIcons; List<String>? _roomBadgeIcons;
String? _roomCover; String? _roomCover;
String? _roomBackground;
String? _roomDesc; String? _roomDesc;
String? _roomGameIcon; String? _roomGameIcon;
String? _roomName; String? _roomName;
@ -135,6 +139,7 @@ class SocialChatRoomRes {
String? roomAccount, String? roomAccount,
List<String>? roomBadgeIcons, List<String>? roomBadgeIcons,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomDesc, String? roomDesc,
String? roomGameIcon, String? roomGameIcon,
String? roomName, String? roomName,
@ -155,6 +160,7 @@ class SocialChatRoomRes {
roomAccount: roomAccount ?? _roomAccount, roomAccount: roomAccount ?? _roomAccount,
roomBadgeIcons: roomBadgeIcons ?? _roomBadgeIcons, roomBadgeIcons: roomBadgeIcons ?? _roomBadgeIcons,
roomCover: roomCover ?? _roomCover, roomCover: roomCover ?? _roomCover,
roomBackground: roomBackground ?? _roomBackground,
roomDesc: roomDesc ?? _roomDesc, roomDesc: roomDesc ?? _roomDesc,
roomGameIcon: roomGameIcon ?? _roomGameIcon, roomGameIcon: roomGameIcon ?? _roomGameIcon,
roomName: roomName ?? _roomName, roomName: roomName ?? _roomName,
@ -185,6 +191,7 @@ class SocialChatRoomRes {
List<String>? get roomBadgeIcons => _roomBadgeIcons; List<String>? get roomBadgeIcons => _roomBadgeIcons;
String? get roomCover => _roomCover; String? get roomCover => _roomCover;
String? get roomBackground => _roomBackground;
String? get roomDesc => _roomDesc; String? get roomDesc => _roomDesc;
@ -228,6 +235,7 @@ class SocialChatRoomRes {
map['roomAccount'] = _roomAccount; map['roomAccount'] = _roomAccount;
map['roomBadgeIcons'] = _roomBadgeIcons; map['roomBadgeIcons'] = _roomBadgeIcons;
map['roomCover'] = _roomCover; map['roomCover'] = _roomCover;
map['roomBackground'] = _roomBackground;
map['roomDesc'] = _roomDesc; map['roomDesc'] = _roomDesc;
map['roomGameIcon'] = _roomGameIcon; map['roomGameIcon'] = _roomGameIcon;
map['roomName'] = _roomName; map['roomName'] = _roomName;

View File

@ -21,6 +21,7 @@ class SCEditRoomInfoRes {
String? roomAccount, String? roomAccount,
String? userId, String? userId,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
String? event, String? event,
@ -38,6 +39,7 @@ class SCEditRoomInfoRes {
_roomAccount = roomAccount; _roomAccount = roomAccount;
_userId = userId; _userId = userId;
_roomCover = roomCover; _roomCover = roomCover;
_roomBackground = roomBackground;
_roomName = roomName; _roomName = roomName;
_roomDesc = roomDesc; _roomDesc = roomDesc;
_event = event; _event = event;
@ -57,6 +59,7 @@ class SCEditRoomInfoRes {
_roomAccount = json['roomAccount']; _roomAccount = json['roomAccount'];
_userId = json['userId']; _userId = json['userId'];
_roomCover = json['roomCover'] ?? json['cover']; _roomCover = json['roomCover'] ?? json['cover'];
_roomBackground = json['roomBackground'];
_roomName = json['roomName']; _roomName = json['roomName'];
_roomDesc = json['roomDesc']; _roomDesc = json['roomDesc'];
_event = json['event']; _event = json['event'];
@ -74,6 +77,7 @@ class SCEditRoomInfoRes {
String? _roomAccount; String? _roomAccount;
String? _userId; String? _userId;
String? _roomCover; String? _roomCover;
String? _roomBackground;
String? _roomName; String? _roomName;
String? _roomDesc; String? _roomDesc;
String? _event; String? _event;
@ -91,6 +95,7 @@ class SCEditRoomInfoRes {
String? roomAccount, String? roomAccount,
String? userId, String? userId,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
String? event, String? event,
@ -108,6 +113,7 @@ class SCEditRoomInfoRes {
roomAccount: roomAccount ?? _roomAccount, roomAccount: roomAccount ?? _roomAccount,
userId: userId ?? _userId, userId: userId ?? _userId,
roomCover: roomCover ?? _roomCover, roomCover: roomCover ?? _roomCover,
roomBackground: roomBackground ?? _roomBackground,
roomName: roomName ?? _roomName, roomName: roomName ?? _roomName,
roomDesc: roomDesc ?? _roomDesc, roomDesc: roomDesc ?? _roomDesc,
event: event ?? _event, event: event ?? _event,
@ -125,6 +131,7 @@ class SCEditRoomInfoRes {
String? get roomAccount => _roomAccount; String? get roomAccount => _roomAccount;
String? get userId => _userId; String? get userId => _userId;
String? get roomCover => _roomCover; String? get roomCover => _roomCover;
String? get roomBackground => _roomBackground;
String? get roomName => _roomName; String? get roomName => _roomName;
String? get roomDesc => _roomDesc; String? get roomDesc => _roomDesc;
String? get event => _event; String? get event => _event;
@ -144,6 +151,7 @@ class SCEditRoomInfoRes {
map['roomAccount'] = _roomAccount; map['roomAccount'] = _roomAccount;
map['userId'] = _userId; map['userId'] = _userId;
map['roomCover'] = _roomCover; map['roomCover'] = _roomCover;
map['roomBackground'] = _roomBackground;
map['roomName'] = _roomName; map['roomName'] = _roomName;
map['roomDesc'] = _roomDesc; map['roomDesc'] = _roomDesc;
map['event'] = _event; map['event'] = _event;

View File

@ -90,6 +90,12 @@ abstract class SocialChatUserRepository {
String event, String event,
); );
///
Future<SCEditRoomInfoRes> updateRoomBackground(
String roomId,
String roomBackground,
);
/// ///
Future<SocialChatUserProfile> loadUserInfo(String userId); Future<SocialChatUserProfile> loadUserInfo(String userId);

View File

@ -76,6 +76,10 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
specificRoom.roomCover, specificRoom.roomCover,
cachedRoom.roomCover, cachedRoom.roomCover,
), ),
roomBackground: SCRoomProfileCache.preferNonEmpty(
specificRoom.roomBackground,
cachedRoom.roomBackground,
),
roomName: SCRoomProfileCache.preferNonEmpty( roomName: SCRoomProfileCache.preferNonEmpty(
specificRoom.roomName, specificRoom.roomName,
cachedRoom.roomName, cachedRoom.roomName,
@ -137,11 +141,12 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
fromJson: (json) => MyRoomRes.fromJson(json), fromJson: (json) => MyRoomRes.fromJson(json),
); );
debugPrint( debugPrint(
"[Room Cover][specific] roomId=${result.id ?? roomId} roomCover=${result.roomCover ?? ""} roomName=${result.roomName ?? ""}", "[Room Cover][specific] roomId=${result.id ?? roomId} roomCover=${result.roomCover ?? ""} roomBackground=${result.roomBackground ?? ""} roomName=${result.roomName ?? ""}",
); );
await SCRoomProfileCache.saveRoomProfile( await SCRoomProfileCache.saveRoomProfile(
roomId: result.id ?? roomId, roomId: result.id ?? roomId,
roomCover: result.roomCover, roomCover: result.roomCover,
roomBackground: result.roomBackground,
roomName: result.roomName, roomName: result.roomName,
roomDesc: result.roomDesc, roomDesc: result.roomDesc,
); );

View File

@ -33,6 +33,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart';
import 'package:yumi/shared/business_logic/repositories/user_repository.dart'; import 'package:yumi/shared/business_logic/repositories/user_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart'; import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
import 'package:yumi/shared/tools/sc_mobile_login_context.dart';
import 'package:yumi/shared/tools/sc_room_profile_cache.dart'; import 'package:yumi/shared/tools/sc_room_profile_cache.dart';
class SCAccountRepository implements SocialChatUserRepository { class SCAccountRepository implements SocialChatUserRepository {
@ -81,9 +82,11 @@ class SCAccountRepository implements SocialChatUserRepository {
///auth/account/login ///auth/account/login
@override @override
Future<SocialChatLoginRes> loginForAccount(String account, String pwd) async { Future<SocialChatLoginRes> loginForAccount(String account, String pwd) async {
final data = <String, dynamic>{"account": account, "pwd": pwd};
data.addAll(await SCMobileLoginContext.loginBodyFields());
final result = await http.post<SocialChatLoginRes>( final result = await http.post<SocialChatLoginRes>(
"e64f27b9ba6b37881120f4584a5444a5c684d8491b703d0af953e5cc6a5f4cec", "e64f27b9ba6b37881120f4584a5444a5c684d8491b703d0af953e5cc6a5f4cec",
data: {"account": account, "pwd": pwd}, data: data,
fromJson: (json) => SocialChatLoginRes.fromJson(json), fromJson: (json) => SocialChatLoginRes.fromJson(json),
); );
return result; return result;
@ -95,9 +98,11 @@ class SCAccountRepository implements SocialChatUserRepository {
String authType, String authType,
String openId, String openId,
) async { ) async {
final data = <String, dynamic>{"authType": authType, "openId": openId};
data.addAll(await SCMobileLoginContext.loginBodyFields());
final result = await http.post<SocialChatLoginRes>( final result = await http.post<SocialChatLoginRes>(
"e64f27b9ba6b37881120f4584a5444a5531a33eb137749a5decad584f58aaa44", "e64f27b9ba6b37881120f4584a5444a5531a33eb137749a5decad584f58aaa44",
data: {"authType": authType, "openId": openId}, data: data,
fromJson: (json) => SocialChatLoginRes.fromJson(json), fromJson: (json) => SocialChatLoginRes.fromJson(json),
); );
return result; return result;
@ -275,6 +280,34 @@ class SCAccountRepository implements SocialChatUserRepository {
return result; return result;
} }
///room/profile/background
@override
Future<SCEditRoomInfoRes> updateRoomBackground(
String roomId,
String roomBackground,
) async {
final payload = {"roomId": roomId, "roomBackground": roomBackground};
debugPrint("[Room Background Save] request payload=$payload");
final result = await http.put(
"/room/profile/background",
data: payload,
fromJson: (json) => SCEditRoomInfoRes.fromJson(json),
);
final resolvedBackground =
(result.roomBackground ?? "").trim().isNotEmpty
? result.roomBackground
: roomBackground;
debugPrint("[Room Background Save] response=${result.toJson()}");
await SCRoomProfileCache.saveRoomProfile(
roomId: result.id ?? roomId,
roomCover: result.roomCover,
roomBackground: resolvedBackground,
roomName: result.roomName,
roomDesc: result.roomDesc,
);
return result.copyWith(roomBackground: resolvedBackground);
}
///user/user-profile ///user/user-profile
@override @override
Future<SocialChatUserProfile> loadUserInfo(String userId) async { Future<SocialChatUserProfile> loadUserInfo(String userId) async {

View File

@ -61,7 +61,12 @@ class SCGiftVapSvgaManager {
void setMute(bool muteMusic) { void setMute(bool muteMusic) {
_mute = muteMusic; _mute = muteMusic;
DataPersistence.setPlayGiftMusic(_mute); _rsc?.muted = _mute;
final vapController = _rgc;
if (vapController != null) {
unawaited(vapController.setMute(_mute));
}
unawaited(DataPersistence.setPlayGiftMusic(_mute));
} }
bool getMute() { bool getMute() {
@ -415,6 +420,7 @@ class SCGiftVapSvgaManager {
_mute = DataPersistence.getPlayGiftMusic(); _mute = DataPersistence.getPlayGiftMusic();
_dis = false; _dis = false;
_rgc = vapController; _rgc = vapController;
unawaited(vapController.setMute(_mute));
_log( _log(
'bindVapCtrl hasVapCtrl=${_rgc != null} ' 'bindVapCtrl hasVapCtrl=${_rgc != null} '
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute', 'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
@ -439,8 +445,10 @@ class SCGiftVapSvgaManager {
} }
void bindSvgaCtrl(SVGAAnimationController svgaController) { void bindSvgaCtrl(SVGAAnimationController svgaController) {
_mute = DataPersistence.getPlayGiftMusic();
_dis = false; _dis = false;
_rsc = svgaController; _rsc = svgaController;
_rsc?.muted = _mute;
_log( _log(
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} ' 'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
'hasVapCtrl=${_rgc != null} queue=$_queueSummary', 'hasVapCtrl=${_rgc != null} queue=$_queueSummary',
@ -565,6 +573,7 @@ class SCGiftVapSvgaManager {
return; return;
} }
_log('use prepared asset svga path=${task.path}'); _log('use prepared asset svga path=${task.path}');
_rsc?.muted = _mute;
_rsc?.videoItem = entity; _rsc?.videoItem = entity;
_rsc?.reset(); _rsc?.reset();
_rsc?.forward(); _rsc?.forward();
@ -591,6 +600,7 @@ class SCGiftVapSvgaManager {
return; return;
} }
_log('play local svga file path=${task.path}'); _log('play local svga file path=${task.path}');
_rsc?.muted = _mute;
_rsc?.videoItem = entity; _rsc?.videoItem = entity;
_rsc?.reset(); _rsc?.reset();
_rsc?.forward(); _rsc?.forward();
@ -634,6 +644,7 @@ class SCGiftVapSvgaManager {
return; return;
} }
_log('use prepared network svga path=${task.path}'); _log('use prepared network svga path=${task.path}');
_rsc?.muted = _mute;
_rsc?.videoItem = entity; _rsc?.videoItem = entity;
_rsc?.reset(); _rsc?.reset();
_rsc?.forward(); _rsc?.forward();

View File

@ -0,0 +1,62 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
class SCLuckyGiftWinSoundPlayer {
static const String assetPath =
"sc_images/room/anim/luck_gift/lucky_gift_win.mp3";
static const Duration _sameEventDedupeWindow = Duration(seconds: 3);
static final AudioPlayer _player = AudioPlayer();
static String? _lastEventKey;
static DateTime? _lastPlayedAt;
static String buildEventKey({
String? giftId,
String? userId,
String? toUserId,
num? awardAmount,
}) {
return [
(giftId ?? '').trim(),
(userId ?? '').trim(),
(toUserId ?? '').trim(),
_formatNum(awardAmount),
].join('|');
}
static Future<void> play({String? eventKey}) async {
if (DataPersistence.getPlayGiftMusic()) {
return;
}
final normalizedEventKey = (eventKey ?? '').trim();
final now = DateTime.now();
final lastPlayedAt = _lastPlayedAt;
if (normalizedEventKey.isNotEmpty &&
normalizedEventKey == _lastEventKey &&
lastPlayedAt != null &&
now.difference(lastPlayedAt) < _sameEventDedupeWindow) {
return;
}
_lastEventKey = normalizedEventKey.isEmpty ? null : normalizedEventKey;
_lastPlayedAt = now;
try {
await _player.stop();
await _player.play(AssetSource(assetPath));
} catch (error) {
debugPrint('播放幸运礼物中奖音效失败: $error');
}
}
static String _formatNum(num? value) {
if (value == null) {
return '';
}
if (value % 1 == 0) {
return value.toInt().toString();
}
return value.toString();
}
}

View File

@ -0,0 +1,61 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class SCMobileLoginContext {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/mobile_context',
);
static Future<Map<String, dynamic>> loginBodyFields() async {
return <String, dynamic>{
'mobileLang': mobileLang,
'mobileZone': await mobileZone(),
};
}
static String get mobileLang {
final locales = ui.PlatformDispatcher.instance.locales;
final locale =
locales.isNotEmpty
? locales.first
: ui.PlatformDispatcher.instance.locale;
final languageTag = locale.toLanguageTag().trim();
if (languageTag.isNotEmpty) {
return languageTag;
}
return locale.languageCode.trim();
}
static Future<String> mobileZone() async {
try {
final zone = await _channel.invokeMethod<String>('getTimeZoneIdentifier');
final value = zone?.trim() ?? '';
if (value.isNotEmpty) {
return value;
}
} on PlatformException catch (e) {
debugPrint('get mobile timezone failed: ${e.code} ${e.message}');
} catch (e) {
debugPrint('get mobile timezone failed: $e');
}
return _fallbackZone();
}
static String _fallbackZone() {
final now = DateTime.now();
final zoneName = now.timeZoneName.trim();
if (zoneName.contains('/')) {
return zoneName;
}
final offset = now.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final absoluteOffset = offset.abs();
final hours = absoluteOffset.inHours.toString().padLeft(2, '0');
final minutes = (absoluteOffset.inMinutes % 60).toString().padLeft(2, '0');
return 'UTC$sign$hours:$minutes';
}
}

View File

@ -57,6 +57,7 @@ class SCRoomProfileCache {
static Future<void> saveRoomProfile({ static Future<void> saveRoomProfile({
required String roomId, required String roomId,
String? roomCover, String? roomCover,
String? roomBackground,
String? roomName, String? roomName,
String? roomDesc, String? roomDesc,
}) async { }) async {
@ -66,6 +67,8 @@ class SCRoomProfileCache {
final current = getRoomProfile(roomId); final current = getRoomProfile(roomId);
final payload = <String, String>{ final payload = <String, String>{
"roomCover": preferNonEmpty(roomCover, current["roomCover"]) ?? "", "roomCover": preferNonEmpty(roomCover, current["roomCover"]) ?? "",
"roomBackground":
preferNonEmpty(roomBackground, current["roomBackground"]) ?? "",
"roomName": preferNonEmpty(roomName, current["roomName"]) ?? "", "roomName": preferNonEmpty(roomName, current["roomName"]) ?? "",
"roomDesc": preferNonEmpty(roomDesc, current["roomDesc"]) ?? "", "roomDesc": preferNonEmpty(roomDesc, current["roomDesc"]) ?? "",
}; };
@ -79,6 +82,10 @@ class SCRoomProfileCache {
} }
return room.copyWith( return room.copyWith(
roomCover: preferCachedValue(cache["roomCover"], room.roomCover), roomCover: preferCachedValue(cache["roomCover"], room.roomCover),
roomBackground: preferCachedValue(
cache["roomBackground"],
room.roomBackground,
),
roomName: preferCachedValue(cache["roomName"], room.roomName), roomName: preferCachedValue(cache["roomName"], room.roomName),
roomDesc: preferCachedValue(cache["roomDesc"], room.roomDesc), roomDesc: preferCachedValue(cache["roomDesc"], room.roomDesc),
); );
@ -96,6 +103,10 @@ class SCRoomProfileCache {
return room.copyWith( return room.copyWith(
roomProfile: roomProfile.copyWith( roomProfile: roomProfile.copyWith(
roomCover: preferCachedValue(cache["roomCover"], roomProfile.roomCover), roomCover: preferCachedValue(cache["roomCover"], roomProfile.roomCover),
roomBackground: preferCachedValue(
cache["roomBackground"],
roomProfile.roomBackground,
),
roomName: preferCachedValue(cache["roomName"], roomProfile.roomName), roomName: preferCachedValue(cache["roomName"], roomProfile.roomName),
roomDesc: preferCachedValue(cache["roomDesc"], roomProfile.roomDesc), roomDesc: preferCachedValue(cache["roomDesc"], roomProfile.roomDesc),
), ),
@ -109,6 +120,10 @@ class SCRoomProfileCache {
} }
return room.copyWith( return room.copyWith(
roomCover: preferCachedValue(cache["roomCover"], room.roomCover), roomCover: preferCachedValue(cache["roomCover"], room.roomCover),
roomBackground: preferCachedValue(
cache["roomBackground"],
room.roomBackground,
),
roomName: preferCachedValue(cache["roomName"], room.roomName), roomName: preferCachedValue(cache["roomName"], room.roomName),
roomDesc: preferCachedValue(cache["roomDesc"], room.roomDesc), roomDesc: preferCachedValue(cache["roomDesc"], room.roomDesc),
); );
@ -131,6 +146,10 @@ class SCRoomProfileCache {
cache["roomCover"], cache["roomCover"],
basicRoomProfile.roomCover, basicRoomProfile.roomCover,
), ),
roomBackground: preferCachedValue(
cache["roomBackground"],
basicRoomProfile.roomBackground,
),
roomName: preferCachedValue( roomName: preferCachedValue(
cache["roomName"], cache["roomName"],
basicRoomProfile.roomName, basicRoomProfile.roomName,

View File

@ -21,16 +21,34 @@ class LGiftAnimalPage extends StatefulWidget {
class _GiftAnimalPageState extends State<LGiftAnimalPage> class _GiftAnimalPageState extends State<LGiftAnimalPage>
with TickerProviderStateMixin { with TickerProviderStateMixin {
static const double _tickerHeight = 52;
static const double _slotExtent = 70;
static const double _entryOffset = 60;
late final GiftAnimationManager _giftAnimationManager; late final GiftAnimationManager _giftAnimationManager;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final availableHeight =
constraints.hasBoundedHeight ? constraints.maxHeight : 332.w;
final visibleSlotCount = _resolveVisibleSlotCount(availableHeight);
_syncVisibleSlotCount(visibleSlotCount);
if (visibleSlotCount <= 0) {
return const SizedBox.shrink();
}
return SizedBox( return SizedBox(
height: 332.w, height: _requiredHeightForSlots(visibleSlotCount),
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: List.generate(4, (index) => _GiftTickerSlot(index: index)), children: List.generate(
visibleSlotCount,
(index) => _GiftTickerSlot(index: index),
), ),
),
);
},
); );
} }
@ -52,8 +70,8 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
void initAnimal() { void initAnimal() {
List<LGiftScrollingScreenAnimsBean> beans = []; List<LGiftScrollingScreenAnimsBean> beans = [];
double top = 60; for (int i = 0; i < GiftAnimationManager.maxVisibleSlots; i++) {
for (int i = 0; i < 4; i++) { final targetTop = i * _slotExtent;
var bean = LGiftScrollingScreenAnimsBean(); var bean = LGiftScrollingScreenAnimsBean();
var controller = AnimationController( var controller = AnimationController(
value: 0, value: 0,
@ -66,10 +84,10 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
duration: const Duration(milliseconds: 280), duration: const Duration(milliseconds: 280),
vsync: this, vsync: this,
); );
bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top); bean.luckyGiftPinnedMargin = EdgeInsets.only(top: targetTop.w);
bean.verticalAnimation = EdgeInsetsTween( bean.verticalAnimation = EdgeInsetsTween(
begin: EdgeInsets.only(top: top), begin: EdgeInsets.only(top: (targetTop + _entryOffset).w),
end: EdgeInsets.only(top: 0), end: EdgeInsets.only(top: targetTop.w),
).animate( ).animate(
CurvedAnimation(parent: controller, curve: Curves.easeOutCubic), CurvedAnimation(parent: controller, curve: Curves.easeOutCubic),
); );
@ -95,11 +113,36 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
), ),
); );
beans.add(bean); beans.add(bean);
top = top + 70;
} }
_giftAnimationManager.attachAnimationControllers(beans); _giftAnimationManager.attachAnimationControllers(beans);
} }
int _resolveVisibleSlotCount(double availableHeight) {
if (!availableHeight.isFinite || availableHeight <= 0) {
return GiftAnimationManager.maxVisibleSlots;
}
final tickerHeight = _tickerHeight.w;
if (availableHeight < tickerHeight) {
return 0;
}
final slotExtent = _slotExtent.w;
final count = ((availableHeight - tickerHeight) / slotExtent).floor() + 1;
return count.clamp(1, GiftAnimationManager.maxVisibleSlots).toInt();
}
double _requiredHeightForSlots(int slotCount) {
return _tickerHeight.w + ((slotCount - 1) * _slotExtent.w);
}
void _syncVisibleSlotCount(int visibleSlotCount) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_giftAnimationManager.setVisibleSlotCount(visibleSlotCount);
});
}
String getBg(String type) { String getBg(String type) {
return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png"; return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png";
} }

View File

@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/services/general/sc_app_general_manager.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_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_lucky_gift_win_sound_player.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/main.dart'; import 'package:yumi/main.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
@ -83,6 +84,17 @@ class _FloatingLuckGiftScreenWidgetState
end: const Offset(-1.5, 0.0), // end: const Offset(-1.5, 0.0), //
).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn)); ).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn));
unawaited(
SCLuckyGiftWinSoundPlayer.play(
eventKey: SCLuckyGiftWinSoundPlayer.buildEventKey(
giftId: widget.message.giftId,
userId: widget.message.userId,
toUserId: widget.message.toUserId,
awardAmount: widget.message.coins,
),
),
);
// //
_controller.addStatusListener((status) { _controller.addStatusListener((status) {
if (status == AnimationStatus.completed && !_isSwipeAnimating) { if (status == AnimationStatus.completed && !_isSwipeAnimating) {

View File

@ -24,6 +24,8 @@ import '../../components/sc_debounce_widget.dart';
class RoomBottomWidget extends StatefulWidget { class RoomBottomWidget extends StatefulWidget {
const RoomBottomWidget({super.key, this.showGiftComboButton = true}); const RoomBottomWidget({super.key, this.showGiftComboButton = true});
static const double floatingButtonHostHeight = _floatingButtonHostHeight;
final bool showGiftComboButton; final bool showGiftComboButton;
@override @override

View File

@ -38,7 +38,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuReport = 5; static const int _menuReport = 5;
static const int _menuBackground = 6; static const int _menuBackground = 6;
static const int _menuMusic = 7; static const int _menuMusic = 7;
static bool get _showBackgroundFeature => false; static bool get _showBackgroundFeature => true;
List<RoomMenu> items2 = []; List<RoomMenu> items2 = [];

View File

@ -0,0 +1,149 @@
## [0.0.13] - 2026-01-20
### 🚀 New Features
- **Volume Control** (#9, PR #10): Added audio volume control to `SVGAAnimationController`
- `controller.volume = 0.5` — Set volume level (0.0 to 1.0)
- `controller.muted = true/false` — Toggle mute without losing volume setting
- Thanks to [@Sansuihe](https://github.com/Sansuihe) for the contribution!
### ⚡ Performance Improvements
- **Memory Optimization** (#8): Reduced Dart heap memory usage by clearing protobuf internal structures after parsing
- Added `freeze()` method to `MovieEntity` that clears raw image data after extraction
- Addresses high memory from `FieldSet`, `PbList`, and `FrameEntity` instances
- Thanks to [@jianleepb](https://github.com/jianleepb) for reporting!
---
## [0.0.12] - 2025-12-24
### 🚀 Improvements
- **Updated Dart SDK constraint** to `^3.10.4` for compatibility with Flutter 3.38.5
### 📝 Maintenance
- **Shortened package description** for pub.dev compliance (under 180 characters)
- **Fixed lint warnings** in generated protobuf files by adding `no_leading_underscores_for_local_identifiers` to ignore rules
---
## [0.0.11] - 2025-12-10
### 📝 Documentation
- **Enhanced README with competitive positioning**
- Added "Why flutter_svga?" section highlighting active maintenance vs archived `svgaplayer_flutter`
- Added comparison table (flutter_svga vs svgaplayer_flutter vs Lottie)
- Added migration guide for users switching from `svgaplayer_flutter`
- **Improved SEO & Discoverability**
- Updated `pubspec.yaml` description with better keywords (actively maintained, alternative, replacement)
- Added relevant topics: animation, svga, vector-graphics, lottie-alternative, ui-effects
---
## [0.0.10] - 2025-12-03
### 📝 Documentation
- Updated README to version 0.0.10 for pub.dev visibility
- Added contributors @Sansuihe and @tungpham6195 to README
---
## [0.0.9] - 2025-12-03
### 🛠 Fixes
- Fixed audio cache collision bug where different SVGA files with the same `audioKey` but different audio content incorrectly shared cached files (#6).
Now uses MD5 hash of audio data to ensure unique cache files.
(Thanks to [@Sansuihe](https://github.com/Sansuihe) for reporting and suggesting the fix.)
### 🚀 Improvements
- Protobuf dependency updated to `^6.0.0`, ensuring compatibility with `retrofit_generator >=10.1.0` (#7).
(Thanks to [@tungpham6195](https://github.com/tungpham6195) for reporting.)
---
## [0.0.8] - 2025-10-18
### 🛠 Fixes
- Fixed repeated music playback bug when reusing the same audio key.
(Thanks to [@wonderkidshihab](https://github.com/wonderkidshihab) for the PR and [@Sansuihe](https://github.com/Sansuihe) for reporting.)
### 🚀 Improvements
- Optimized audio handling and cache logic for better playback stability.
- Updated README and credits for contributors.
- Version bump for pub.dev publication.
---
## [0.0.7] - 2025-10-18
### Fixed
- 🎵 Fixed repeated music playback bug when same audio keys are used (#3)
- 🛡️ Improved race condition handling in audio playback logic
- ✅ Added early return check to prevent duplicate audio playback
### Maintenance
- Enhanced audio layer synchronization and error handling
---
## [0.0.6] - 2025-06-26
### Fixed
- 🎧 Prevent crash when AudioPlayer is accessed after dispose (#1)
- 🛠️ Updated to support protobuf 4+, other latest dependencies and use Flutter 3.32.5 (#2)
### Maintenance
- Improved safety checks in SVGAAudioLayer
- Compatible with Dart 3.0 and Flutter 3.32+
---
## 0.0.5 - Update (2025-03-14)
### 🔥 Upgrade to flutter version 3.29.2
---
## 0.0.4 - Update (2025-02-09)
### 🔥 New Features & Improvements
- ✅ **Added Audio Support**: Integrated audio playback within SVGA animations using the `audioplayers` package.
---
## 0.0.3 - Update (2025-01-30)
### 🔥 New Features & Improvements
- ✅ **Added Pause & Resume Playback Functions** for SVGA animations.
- ✅ **Enhanced Error Handling & Logging** for better debugging.
- ✅ **Improved Performance** when loading SVGA animations.
### 🛠 Fixes & Optimizations
- 🛠 Fixed potential crashes when loading SVGA assets.
- 🛠 Optimized memory usage for large SVGA files.
- 🛠 Improved logging messages for debugging.
---
## 0.0.2 - Update (2025-01-30)
### 🔥 Updates & Improvements
- ✅ Added **example GIFs** for better demonstration.
- ✅ **Supported all platforms** including **Web & Desktop**.
---
## 0.0.1 - Initial Release (2025-01-29)
### 🎉 New Features
- ✅ Added support for **SVGA parsing and rendering**.
- ✅ Load **SVGA animations** from **assets** and **network URLs**.
- ✅ Implemented **SVGAAnimationController** for playback control.
- ✅ Dynamic entity support (text & images).
- ✅ Optimized performance for **smooth animations**.

View File

@ -0,0 +1,26 @@
---
### **📄 `LICENSE`** (MIT License)
```plaintext
MIT License
Copyright (c) 2025 5alafawyyy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,353 @@
# flutter_svga
A **Flutter package** for parsing and rendering **SVGA animations** efficiently.
SVGA is a lightweight and powerful animation format used for **dynamic UI effects** in mobile applications.
<p align="center">
<img src="https://raw.githubusercontent.com/5alafawyyy/flutter_svga/master/example.gif" width="300"/>
<img src="https://raw.githubusercontent.com/5alafawyyy/flutter_svga/master/example1.gif" width="300"/>
</p>
---
## 🎯 **Why flutter_svga?**
### ✅ **Actively Maintained**
Unlike **`svgaplayer_flutter`** (archived since Feb 2023), `flutter_svga` is **actively maintained** with regular updates, bug fixes, and community support.
### ⚡ **High Performance**
- **Intelligent caching system** reduces network usage and load times
- **Binary format** smaller than JSON-based Lottie files
- **Optimized rendering** for smooth 60 FPS animations
### 🎨 **Feature-Rich**
- **Audio playback** integrated directly in animations
- **Dynamic elements** (replace text, images, colors at runtime)
- **Full platform support** (Android, iOS, Web, macOS, Linux, Windows)
### 📦 **Comparison**
| Feature | flutter_svga | svgaplayer_flutter | Lottie |
|---------|--------------|-------------------|--------|
| **Status** | ✅ **Active** | ❌ Archived (Feb 2023) | ✅ Active |
| **Caching** | ✅ Built-in intelligent cache | ❌ No | ⚠️ Manual |
| **Audio Support** | ✅ Integrated | ❌ No | ✅ Yes |
| **File Size** | 🟢 Small (binary) | 🟢 Small (binary) | 🟡 Larger (JSON) |
| **Dynamic Elements** | ✅ Text, Images, Drawers | ⚠️ Limited | ✅ Yes |
| **Platform Support** | ✅ All 6 platforms | ⚠️ Mobile only | ✅ All platforms |
| **Performance** | ⚡ Optimized | ⚡ Good | ⚡ Good |
---
## 🔄 **Migrating from svgaplayer_flutter**
Switching from the archived `svgaplayer_flutter` is simple:
### 1. Update Dependencies
```yaml
dependencies:
# svgaplayer_flutter: ^2.2.0 # Remove old package
flutter_svga: ^0.0.13 # Add new package
```
### 2. Update Imports
```dart
// Old
// import 'package:svgaplayer_flutter/svgaplayer_flutter.dart';
// New
import 'package:flutter_svga/flutter_svga.dart';
```
### 3. API Usage (mostly compatible)
The API is similar, with some enhancements:
```dart
// Both packages use similar controller patterns
SVGAAnimationController controller = SVGAAnimationController(vsync: this);
// Loading remains the same
final videoItem = await SVGAParser.shared.decodeFromAssets("assets/animation.svga");
controller.videoItem = videoItem;
controller.repeat();
```
**🎉 Bonus**: You now get **automatic caching**, **audio support**, and **better performance** with zero code changes!
---
## 🚀 **Features**
✔️ Parse and render **SVGA animations** in Flutter.
✔️ Load SVGA files from **assets** and **network URLs**.
✔️ **Intelligent caching system** for faster loading and reduced network usage.
✔️ Supports **custom dynamic elements** (text, images, animations).
✔️ **Optimized playback performance** with animation controllers.
✔️ **Integrated audio playback** within SVGA animations.
✔️ Works on **Android & iOS** (Web & Desktop support coming soon).
✔️ Easy **loop, stop, and seek** functions.
---
## 📌 **Installation**
Add **flutter_svga** to your `pubspec.yaml`:
```yaml
dependencies:
flutter_svga: ^0.0.13
```
Then, install dependencies:
```sh
flutter pub get
```
---
## 🎬 **Basic Usage**
### ✅ **Playing an SVGA Animation from Assets**
```dart
import 'package:flutter/material.dart';
import 'package:flutter_svga/flutter_svga.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text("Flutter SVGA Example")),
body: Center(
child: SVGAEasyPlayer(
assetsName: "assets/sample_with_audio.svga",
fit: BoxFit.contain,
),
),
),
);
}
}
```
---
## 🌍 **Playing SVGA from a Network URL**
```dart
SVGAEasyPlayer(
resUrl: "https://example.com/sample.svga",
fit: BoxFit.cover,
);
```
---
## 🎭 **Advanced Usage: Using SVGAAnimationController**
### ✅ **Controlling Animation Playback**
```dart
class MySVGAWidget extends StatefulWidget {
@override
_MySVGAWidgetState createState() => _MySVGAWidgetState();
}
class _MySVGAWidgetState extends State<MySVGAWidget>
with SingleTickerProviderStateMixin {
late SVGAAnimationController _controller;
@override
void initState() {
super.initState();
_controller = SVGAAnimationController(vsync: this);
SVGAParser.shared.decodeFromAssets("assets/sample.svga").then((video) {
_controller.videoItem = video;
_controller.repeat();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SVGAImage(_controller);
}
}
```
---
## 🎨 **Customization & Dynamic Elements**
### ✅ **Adding Dynamic Text**
```dart
controller.videoItem!.dynamicItem.setText(
TextPainter(
text: TextSpan(
text: "Hello SVGA!",
style: TextStyle(color: Colors.red, fontSize: 18),
),
textDirection: TextDirection.ltr,
),
"text_layer",
);
```
---
### ✅ **Replacing an Image Dynamically**
```dart
controller.videoItem!.dynamicItem.setImageWithUrl(
"https://example.com/new_image.png",
"image_layer",
);
```
---
### ✅ **Hiding a Layer**
```dart
controller.videoItem!.dynamicItem.setHidden(true, "layer_to_hide");
```
---
## 🗄️ **Caching (New!)**
**Automatic performance optimization with zero breaking changes:**
```dart
// Caching works automatically - no code changes needed!
final animation = await SVGAParser.shared.decodeFromURL(
"https://example.com/animation.svga"
);
// Optional: Configure cache settings
SVGACache.shared.setMaxCacheSize(50 * 1024 * 1024); // 50MB
SVGACache.shared.setMaxAge(const Duration(days: 3)); // 3 days
// Optional: Manage cache
await SVGACache.shared.clear(); // Clear all cache
final stats = await SVGACache.shared.getStats(); // Get cache info
```
**📋 See [CACHE.md](CACHE.md) for complete caching documentation and examples.**
---
## 🎯 **Playback Controls**
```dart
controller.forward(); // Play once
controller.repeat(); // Loop playback
controller.stop(); // Stop animation
controller.value = 0; // Reset to first frame
```
---
## 🔊 **Audio Volume Control**
```dart
// Set volume (0.0 to 1.0)
controller.volume = 0.5;
// Mute audio (preserves volume setting)
controller.muted = true;
// Unmute audio
controller.muted = false;
```
---
## 🛠 **Common Issues & Solutions**
### ❌ **Black Screen when Loading SVGA**
**Solution:** Ensure your `svga` files are correctly placed inside `assets/` and registered in `pubspec.yaml`.
```yaml
flutter:
assets:
- assets/sample.svga
```
---
### ❌ **SVGA Not Loading from Network**
**Solution:** Ensure the SVGA file is accessible via HTTPS. Test the URL in a browser.
```dart
SVGAEasyPlayer(
resUrl: "https://example.com/sample.svga",
fit: BoxFit.cover,
);
```
---
### ❌ **Animation Freezes or Doesn't Play**
**Solution:** Use `setState` after loading SVGA to rebuild the widget.
```dart
setState(() {
_controller.videoItem = video;
});
```
---
## 📱 **Supported Platforms**
| Platform | Supported | Audio Support |
|----------|-----------|---------------|
| ✅ Android | ✔️ Yes | ✔️ Yes |
| ✅ iOS | ✔️ Yes | ✔️ Yes |
| ✅ Linux | ✔️ Yes | ✔️ Yes |
| ✅ Web | ✔️ Yes | ❌ No |
| ✅ macOS | ✔️ Yes | ✔️ Yes |
| ✅ Desktop | ✔️ Yes | ✔️ Yes |
---
## 🔄 **Changelog**
See the latest changes in [`CHANGELOG.md`](CHANGELOG.md).
---
## 📜 **License**
This package is licensed under the **MIT License**. See [`LICENSE`](LICENSE) for details.
---
## 🤝 **Contributing**
- If you find a **bug**, report it [here](https://github.com/5alafawyyy/flutter_svga/issues).
- Pull requests are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines.
---
## 👨‍💻 **Authors & Contributors**
### 🏗 **Core Author**
- **[5alafawyyy](https://github.com/5alafawyyy)** — Lead Developer, Maintainer, and Flutter Integration Engineer.
### 🤝 **Contributors**
Special thanks to the amazing contributors who improved **flutter_svga**:
| Contributor | Contribution | GitHub |
|--------------|--------------|--------|
| **[wonderkidshihab](https://github.com/wonderkidshihab)** | Fixed repeated music playback bug (#3) | 🧩 |
| **[Sansuihe](https://github.com/Sansuihe)** | Identified and proposed MD5-based fix for audio cache collision (#6) | 🎵 |
| **[tungpham6195](https://github.com/tungpham6195)** | Reported protobuf dependency compatibility issue (#7) | 📦 |
> Want to contribute? Read [CONTRIBUTING.md](CONTRIBUTING.md) and submit your PR — wed love your help!
---
🚀 **Enjoy using SVGA animations in your Flutter app!** 🚀

View File

@ -0,0 +1,12 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
# Exclude generated protobuf files from analysis
analyzer:
exclude:
- lib/src/proto/*.pb.dart
- lib/src/proto/*.pbenum.dart
- lib/src/proto/*.pbjson.dart
- lib/src/proto/*.pbserver.dart

View File

@ -0,0 +1,6 @@
export 'src/cache.dart';
export 'src/dynamic_entity.dart';
export 'src/parser.dart';
export 'src/player.dart';
export 'src/proto/svga.pb.dart'
show MovieEntity, MovieParams, ShapeEntity, FrameEntity;

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'proto/svga.pb.dart';
class SVGAAudioLayer {
final AudioPlayer _player = AudioPlayer();
late final AudioEntity audioItem;
late final MovieEntity _videoItem;
bool _isReady = false;
bool _disposed = false;
double _volume = 1.0;
SVGAAudioLayer(this.audioItem, this._videoItem);
Future<void> playAudio({double? volume}) async {
if (_disposed) return;
// Prevent duplicate playback if already playing or preparing to play
if (_isReady || isPlaying()) return;
final audioData = _videoItem.audiosData[audioItem.audioKey];
if (audioData != null) {
// https://github.com/bluefireteam/audioplayers/issues/1782
// If need use Bytes, plz upgrade to audioplayers: ^6.0.0
// BytesSource source = BytesSource(audioData);
final cacheDir = await getApplicationCacheDirectory();
if (_disposed) return;
// Use MD5 hash to ensure unique cache files even when audioKeys collide
// across different SVGA files with different audio content
final audioHash = md5.convert(audioData).toString();
final cacheFile = File(
'${cacheDir.path}/temp_${audioItem.audioKey}_$audioHash.mp3',
);
if (!cacheFile.existsSync()) {
await cacheFile.writeAsBytes(audioData);
if (_disposed) return;
}
try {
_isReady = true;
if (_disposed) {
_isReady = false;
return;
}
// Apply volume before playing
if (volume != null) {
_volume = volume.clamp(0.0, 1.0);
}
await _player.setVolume(_volume);
if (_disposed) {
_isReady = false;
return;
}
await _player.play(DeviceFileSource(cacheFile.path));
_isReady = false;
// I noticed that this logic exists in the iOS code of SVGAPlayer
// but it seems unnecessary.
// _player.seek(Duration(milliseconds: audioItem.startTime.toInt()));
} catch (e) {
_isReady = false;
log('Failed to play audio: $e');
}
}
}
/// Sets the volume for this audio layer.
/// [volume] should be between 0.0 (mute) and 1.0 (max volume).
void setVolume(double volume) {
if (_disposed) return;
_volume = volume.clamp(0.0, 1.0);
_player.setVolume(_volume);
}
void pauseAudio() {
if (_disposed) return;
_player.pause();
}
void resumeAudio() {
if (_disposed) return;
_player.resume();
}
void stopAudio() {
if (_disposed) return;
if (isPlaying() || isPaused()) unawaited(_player.stop());
}
bool isPlaying() {
if (_disposed) return false;
return _player.state == PlayerState.playing;
}
bool isPaused() {
if (_disposed) return false;
return _player.state == PlayerState.paused;
}
Future<void> dispose() async {
if (_disposed) return;
if (isPlaying() || isPaused()) {
await _player.stop();
}
_disposed = true;
await _player.dispose();
}
}

View File

@ -0,0 +1,261 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
/// Cache manager for SVGA files to improve loading performance.
/// Provides persistent disk caching for parsed SVGA data.
class SVGACache {
static SVGACache? _instance;
static SVGACache get shared => _instance ??= SVGACache._();
SVGACache._();
Directory? _cacheDir;
bool _enabled = true;
int _maxCacheSize = 100 * 1024 * 1024; // 100MB default
Duration _maxAge = const Duration(days: 7); // 7 days default
/// Whether caching is enabled. Defaults to true.
bool get isEnabled => _enabled;
/// Maximum cache size in bytes. Defaults to 100MB.
int get maxCacheSize => _maxCacheSize;
/// Maximum age for cached files. Defaults to 7 days.
Duration get maxAge => _maxAge;
/// Enable or disable caching.
void setEnabled(bool enabled) {
_enabled = enabled;
}
/// Set maximum cache size in bytes.
void setMaxCacheSize(int sizeInBytes) {
_maxCacheSize = sizeInBytes;
}
/// Set maximum age for cached files.
void setMaxAge(Duration duration) {
_maxAge = duration;
}
/// Initialize cache directory
Future<void> _ensureCacheDir() async {
if (_cacheDir != null) return;
try {
final tempDir = await getTemporaryDirectory();
_cacheDir = Directory('${tempDir.path}/svga_cache');
if (!await _cacheDir!.exists()) {
await _cacheDir!.create(recursive: true);
}
} catch (e) {
// If cache directory creation fails, disable caching
_enabled = false;
}
}
/// Generate cache key from source identifier
String _generateCacheKey(String source) {
return md5.convert(utf8.encode(source)).toString();
}
/// Get cache file path for a source
Future<File?> _getCacheFile(String source) async {
if (!_enabled) return null;
await _ensureCacheDir();
if (_cacheDir == null) return null;
final key = _generateCacheKey(source);
return File('${_cacheDir!.path}/$key.svga');
}
/// Check if cached data exists and is still valid
Future<bool> _isCacheValid(File cacheFile) async {
if (!await cacheFile.exists()) return false;
final stat = await cacheFile.stat();
final age = DateTime.now().difference(stat.modified);
return age <= _maxAge;
}
/// Get cached raw bytes if available and valid
Future<Uint8List?> getRawBytes(String source) async {
if (!_enabled) return null;
try {
final cacheFile = await _getCacheFile(source);
if (cacheFile == null) return null;
if (!await _isCacheValid(cacheFile)) {
// Remove expired cache
await cacheFile.delete().catchError((_) => cacheFile);
return null;
}
return await cacheFile.readAsBytes();
} catch (e) {
// If cache read fails, return null to fallback to normal loading
return null;
}
}
/// Store raw SVGA bytes in cache
Future<void> putRawBytes(String source, Uint8List bytes) async {
if (!_enabled) return;
try {
final cacheFile = await _getCacheFile(source);
if (cacheFile == null) return;
// Write the raw bytes to cache
await cacheFile.writeAsBytes(bytes);
// Clean up cache if needed
await _cleanupCache();
} catch (e) {
// If cache write fails, silently continue without caching
}
}
/// Remove specific item from cache
Future<void> remove(String source) async {
if (!_enabled) return;
try {
final cacheFile = await _getCacheFile(source);
if (cacheFile != null && await cacheFile.exists()) {
await cacheFile.delete();
}
} catch (e) {
// Silently fail
}
}
/// Clear all cached data
Future<void> clear() async {
if (!_enabled || _cacheDir == null) return;
try {
await _ensureCacheDir();
if (_cacheDir != null && await _cacheDir!.exists()) {
await _cacheDir!.delete(recursive: true);
await _cacheDir!.create(recursive: true);
}
} catch (e) {
// Silently fail
}
}
/// Get current cache size in bytes
Future<int> getCacheSize() async {
if (!_enabled || _cacheDir == null) return 0;
try {
await _ensureCacheDir();
if (_cacheDir == null || !await _cacheDir!.exists()) return 0;
int totalSize = 0;
await for (final entity in _cacheDir!.list()) {
if (entity is File) {
final stat = await entity.stat();
totalSize += stat.size;
}
}
return totalSize;
} catch (e) {
return 0;
}
}
/// Clean up expired cache and enforce size limits
Future<void> _cleanupCache() async {
if (!_enabled || _cacheDir == null) return;
try {
await _ensureCacheDir();
if (_cacheDir == null || !await _cacheDir!.exists()) return;
final files = <File>[];
await for (final entity in _cacheDir!.list()) {
if (entity is File && entity.path.endsWith('.svga')) {
files.add(entity);
}
}
// Remove expired files
final now = DateTime.now();
for (final file in files.toList()) {
final stat = await file.stat();
if (now.difference(stat.modified) > _maxAge) {
await file.delete().catchError((_) => file);
files.remove(file);
}
}
// Get file stats for sorting
final fileStats = <File, FileStat>{};
for (final file in files) {
fileStats[file] = await file.stat();
}
// Sort by modification time (oldest first)
files.sort((a, b) {
final statA = fileStats[a]!;
final statB = fileStats[b]!;
return statA.modified.compareTo(statB.modified);
});
// Calculate total size
int totalSize = 0;
for (final file in files) {
totalSize += fileStats[file]!.size;
}
// Remove oldest files until under size limit
while (totalSize > _maxCacheSize && files.isNotEmpty) {
final oldestFile = files.removeAt(0);
final stat = fileStats[oldestFile]!;
totalSize -= stat.size;
await oldestFile.delete().catchError((_) => oldestFile);
}
} catch (e) {
// Silently fail
}
}
/// Get cache statistics
Future<Map<String, dynamic>> getStats() async {
final size = await getCacheSize();
int fileCount = 0;
if (_enabled && _cacheDir != null) {
try {
await _ensureCacheDir();
if (_cacheDir != null && await _cacheDir!.exists()) {
await for (final entity in _cacheDir!.list()) {
if (entity is File && entity.path.endsWith('.svga')) {
fileCount++;
}
}
}
} catch (e) {
// Silently fail
}
}
return {
'enabled': _enabled,
'size': size,
'maxSize': _maxCacheSize,
'fileCount': fileCount,
'maxAge': _maxAge.inDays,
};
}
}

View File

@ -0,0 +1,45 @@
import 'dart:ui' as ui show Image;
import 'package:flutter/painting.dart';
import 'package:http/http.dart';
typedef SVGACustomDrawer = Function(Canvas canvas, int frameIndex);
class SVGADynamicEntity {
final Map<String, bool> dynamicHidden = {};
final Map<String, ui.Image> dynamicImages = {};
final Map<String, TextPainter> dynamicText = {};
final Map<String, SVGACustomDrawer> dynamicDrawer = {};
void setHidden(bool value, String forKey) {
dynamicHidden[forKey] = value;
}
void setImage(ui.Image image, String forKey) {
dynamicImages[forKey] = image;
}
Future<void> setImageWithUrl(String url, String forKey) async {
dynamicImages[forKey] =
await decodeImageFromList((await get(Uri.parse(url))).bodyBytes);
}
void setText(TextPainter textPainter, String forKey) {
if (textPainter.textDirection == null) {
textPainter.textDirection = TextDirection.ltr;
textPainter.layout();
}
dynamicText[forKey] = textPainter;
}
void setDynamicDrawer(SVGACustomDrawer drawer, String forKey) {
dynamicDrawer[forKey] = drawer;
}
void reset() {
dynamicHidden.clear();
dynamicImages.clear();
dynamicText.clear();
dynamicDrawer.clear();
}
}

View File

@ -0,0 +1,96 @@
part of 'player.dart';
class SVGAEasyPlayer extends StatefulWidget {
final String? resUrl;
final String? assetsName;
final BoxFit fit;
const SVGAEasyPlayer({
super.key,
this.resUrl,
this.assetsName,
this.fit = BoxFit.contain,
});
@override
State<StatefulWidget> createState() {
return _SVGAEasyPlayerState();
}
}
class _SVGAEasyPlayerState extends State<SVGAEasyPlayer>
with SingleTickerProviderStateMixin {
SVGAAnimationController? animationController;
@override
void initState() {
super.initState();
animationController = SVGAAnimationController(vsync: this);
_tryDecodeSvga();
}
@override
void didUpdateWidget(covariant SVGAEasyPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.resUrl != widget.resUrl ||
oldWidget.assetsName != widget.assetsName) {
_tryDecodeSvga();
}
}
@override
Widget build(BuildContext context) {
if (animationController == null) {
return Container();
}
return SVGAImage(
animationController!,
fit: widget.fit,
);
}
@override
void dispose() {
animationController?.dispose();
animationController = null;
super.dispose();
}
void _tryDecodeSvga() {
Future<MovieEntity> decode;
if (widget.resUrl != null) {
decode = SVGAParser.shared.decodeFromURL(widget.resUrl!);
} else if (widget.assetsName != null) {
decode = SVGAParser.shared.decodeFromAssets(widget.assetsName!);
} else {
return;
}
decode.then((videoItem) {
if (mounted && animationController != null) {
animationController!
..videoItem = videoItem
..repeat();
} else {
videoItem.dispose();
}
}).catchError(
(e, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stack,
library: 'SVGAEasyPlayer',
context: ErrorDescription('during _tryDecodeSvga'),
informationCollector: () => [
if (widget.resUrl != null)
StringProperty('resUrl', widget.resUrl),
if (widget.assetsName != null)
StringProperty('assetsName', widget.assetsName),
],
),
);
},
);
}
}

View File

@ -0,0 +1,463 @@
part of 'player.dart';
class _SVGAPainter extends CustomPainter {
final BoxFit fit;
final SVGAAnimationController controller;
int get currentFrame => controller.currentFrame;
MovieEntity get videoItem => controller.videoItem!;
final FilterQuality filterQuality;
/// Guaranteed to draw within the canvas bounds
final bool clipRect;
_SVGAPainter(
this.controller, {
this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low,
this.clipRect = true,
}) : assert(
controller.videoItem != null, 'Invalid SVGAAnimationController!'),
super(repaint: controller);
@override
void paint(Canvas canvas, Size size) {
if (controller._canvasNeedsClear) {
// mark cleared
controller._canvasNeedsClear = false;
return;
}
if (size.isEmpty || controller.videoItem == null) return;
final params = videoItem.params;
final Size viewBoxSize = Size(params.viewBoxWidth, params.viewBoxHeight);
if (viewBoxSize.isEmpty) return;
canvas.save();
try {
final canvasRect = Offset.zero & size;
if (clipRect) canvas.clipRect(canvasRect);
scaleCanvasToViewBox(canvas, canvasRect, Offset.zero & viewBoxSize);
drawSprites(canvas, size);
} finally {
canvas.restore();
}
}
void scaleCanvasToViewBox(Canvas canvas, Rect canvasRect, Rect viewBoxRect) {
final fittedSizes = applyBoxFit(fit, viewBoxRect.size, canvasRect.size);
// scale viewbox size (source) to canvas size (destination)
var sx = fittedSizes.destination.width / fittedSizes.source.width;
var sy = fittedSizes.destination.height / fittedSizes.source.height;
final Size scaledHalfViewBoxSize =
Size(viewBoxRect.size.width * sx, viewBoxRect.size.height * sy) / 2.0;
final Size halfCanvasSize = canvasRect.size / 2.0;
// center align
final Offset shift = Offset(
halfCanvasSize.width - scaledHalfViewBoxSize.width,
halfCanvasSize.height - scaledHalfViewBoxSize.height,
);
if (shift != Offset.zero) canvas.translate(shift.dx, shift.dy);
if (sx != 1.0 && sy != 1.0) canvas.scale(sx, sy);
}
void drawSprites(Canvas canvas, Size size) {
for (final sprite in videoItem.sprites) {
final imageKey = sprite.imageKey;
// var matteKey = sprite.matteKey;
if (imageKey.isEmpty ||
videoItem.dynamicItem.dynamicHidden[imageKey] == true) {
continue;
}
final frameItem = sprite.frames[currentFrame];
final needTransform = frameItem.hasTransform();
final needClip = frameItem.hasClipPath();
if (needTransform) {
canvas.save();
canvas.transform(Float64List.fromList(<double>[
frameItem.transform.a,
frameItem.transform.b,
0.0,
0.0,
frameItem.transform.c,
frameItem.transform.d,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
frameItem.transform.tx,
frameItem.transform.ty,
0.0,
1.0
]));
}
if (needClip) {
canvas.save();
canvas.clipPath(buildDPath(frameItem.clipPath));
}
final frameRect =
Rect.fromLTRB(0, 0, frameItem.layout.width, frameItem.layout.height);
final frameAlpha =
frameItem.hasAlpha() ? (frameItem.alpha * 255).toInt() : 255;
drawBitmap(canvas, imageKey, frameRect, frameAlpha);
drawShape(canvas, frameItem.shapes, frameAlpha);
// draw dynamic
final dynamicDrawer = videoItem.dynamicItem.dynamicDrawer[imageKey];
if (dynamicDrawer != null) {
dynamicDrawer(canvas, currentFrame);
}
if (needClip) {
canvas.restore();
}
if (needTransform) {
canvas.restore();
}
}
}
void drawBitmap(Canvas canvas, String imageKey, Rect frameRect, int alpha) {
final bitmap = videoItem.dynamicItem.dynamicImages[imageKey] ??
videoItem.bitmapCache[imageKey];
if (bitmap == null) return;
final bitmapPaint = Paint();
bitmapPaint.filterQuality = filterQuality;
//bitmap锯齿问题
bitmapPaint.isAntiAlias = true;
bitmapPaint.color = Color.fromARGB(alpha, 0, 0, 0);
Rect srcRect =
Rect.fromLTRB(0, 0, bitmap.width.toDouble(), bitmap.height.toDouble());
Rect dstRect = frameRect;
canvas.drawImageRect(bitmap, srcRect, dstRect, bitmapPaint);
drawTextOnBitmap(canvas, imageKey, frameRect, alpha);
}
void drawShape(Canvas canvas, List<ShapeEntity> shapes, int frameAlpha) {
if (shapes.isEmpty) return;
for (var shape in shapes) {
final path = buildPath(shape);
if (shape.hasTransform()) {
canvas.save();
canvas.transform(Float64List.fromList(<double>[
shape.transform.a,
shape.transform.b,
0.0,
0.0,
shape.transform.c,
shape.transform.d,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
shape.transform.tx,
shape.transform.ty,
0.0,
1.0
]));
}
final fill = shape.styles.fill;
if (fill.isInitialized()) {
final paint = Paint();
paint.isAntiAlias = true;
paint.style = PaintingStyle.fill;
paint.color = Color.fromARGB(
(fill.a * frameAlpha).toInt(),
(fill.r * 255).toInt(),
(fill.g * 255).toInt(),
(fill.b * 255).toInt(),
);
canvas.drawPath(path, paint);
}
final strokeWidth = shape.styles.strokeWidth;
if (strokeWidth > 0) {
final paint = Paint();
paint.style = PaintingStyle.stroke;
if (shape.styles.stroke.isInitialized()) {
paint.color = Color.fromARGB(
(shape.styles.stroke.a * frameAlpha).toInt(),
(shape.styles.stroke.r * 255).toInt(),
(shape.styles.stroke.g * 255).toInt(),
(shape.styles.stroke.b * 255).toInt(),
);
}
paint.strokeWidth = strokeWidth;
final lineCap = shape.styles.lineCap;
switch (lineCap) {
case ShapeEntity_ShapeStyle_LineCap.LineCap_BUTT:
paint.strokeCap = StrokeCap.butt;
break;
case ShapeEntity_ShapeStyle_LineCap.LineCap_ROUND:
paint.strokeCap = StrokeCap.round;
break;
case ShapeEntity_ShapeStyle_LineCap.LineCap_SQUARE:
paint.strokeCap = StrokeCap.square;
break;
default:
}
final lineJoin = shape.styles.lineJoin;
switch (lineJoin) {
case ShapeEntity_ShapeStyle_LineJoin.LineJoin_MITER:
paint.strokeJoin = StrokeJoin.miter;
break;
case ShapeEntity_ShapeStyle_LineJoin.LineJoin_ROUND:
paint.strokeJoin = StrokeJoin.round;
break;
case ShapeEntity_ShapeStyle_LineJoin.LineJoin_BEVEL:
paint.strokeJoin = StrokeJoin.bevel;
break;
default:
}
paint.strokeMiterLimit = shape.styles.miterLimit;
List<double> lineDash = [
shape.styles.lineDashI,
shape.styles.lineDashII,
shape.styles.lineDashIII
];
if (lineDash[0] > 0 || lineDash[1] > 0) {
canvas.drawPath(
dashPath(
path,
dashArray: CircularIntervalList([
lineDash[0] < 1.0 ? 1.0 : lineDash[0],
lineDash[1] < 0.1 ? 0.1 : lineDash[1],
]),
dashOffset: DashOffset.absolute(lineDash[2]),
),
paint);
} else {
canvas.drawPath(path, paint);
}
}
if (shape.hasTransform()) {
canvas.restore();
}
}
}
static const _validMethods = 'MLHVCSQRZmlhvcsqrz';
Path buildPath(ShapeEntity shape) {
final path = Path();
if (shape.type == ShapeEntity_ShapeType.SHAPE) {
final args = shape.shape;
final argD = args.d;
return buildDPath(argD, path: path);
} else if (shape.type == ShapeEntity_ShapeType.ELLIPSE) {
final args = shape.ellipse;
final xv = args.x;
final yv = args.y;
final rxv = args.radiusX;
final ryv = args.radiusY;
final rect = Rect.fromLTWH(xv - rxv, yv - ryv, rxv * 2, ryv * 2);
if (!rect.isEmpty) path.addOval(rect);
} else if (shape.type == ShapeEntity_ShapeType.RECT) {
final args = shape.rect;
final xv = args.x;
final yv = args.y;
final wv = args.width;
final hv = args.height;
final crv = args.cornerRadius;
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(xv, yv, wv, hv), Radius.circular(crv));
if (!rrect.isEmpty) path.addRRect(rrect);
}
return path;
}
Path buildDPath(String argD, {Path? path}) {
if (videoItem.pathCache[argD] != null) {
return videoItem.pathCache[argD]!;
}
path ??= Path();
final d = argD.replaceAllMapped(RegExp('([a-df-zA-Z])'), (match) {
return "|||${match.group(1)} ";
}).replaceAll(RegExp(","), " ");
var currentPointX = 0.0;
var currentPointY = 0.0;
double? currentPointX1;
double? currentPointY1;
double? currentPointX2;
double? currentPointY2;
d.split("|||").forEach((segment) {
if (segment.isEmpty) {
return;
}
final firstLetter = segment.substring(0, 1);
if (_validMethods.contains(firstLetter)) {
final args = segment.substring(1).trim().split(" ");
if (firstLetter == "M") {
currentPointX = double.parse(args[0]);
currentPointY = double.parse(args[1]);
path!.moveTo(currentPointX, currentPointY);
} else if (firstLetter == "m") {
currentPointX += double.parse(args[0]);
currentPointY += double.parse(args[1]);
path!.moveTo(currentPointX, currentPointY);
} else if (firstLetter == "L") {
currentPointX = double.parse(args[0]);
currentPointY = double.parse(args[1]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "l") {
currentPointX += double.parse(args[0]);
currentPointY += double.parse(args[1]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "H") {
currentPointX = double.parse(args[0]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "h") {
currentPointX += double.parse(args[0]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "V") {
currentPointY = double.parse(args[0]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "v") {
currentPointY += double.parse(args[0]);
path!.lineTo(currentPointX, currentPointY);
} else if (firstLetter == "C") {
currentPointX1 = double.parse(args[0]);
currentPointY1 = double.parse(args[1]);
currentPointX2 = double.parse(args[2]);
currentPointY2 = double.parse(args[3]);
currentPointX = double.parse(args[4]);
currentPointY = double.parse(args[5]);
path!.cubicTo(
currentPointX1!,
currentPointY1!,
currentPointX2!,
currentPointY2!,
currentPointX,
currentPointY,
);
} else if (firstLetter == "c") {
currentPointX1 = currentPointX + double.parse(args[0]);
currentPointY1 = currentPointY + double.parse(args[1]);
currentPointX2 = currentPointX + double.parse(args[2]);
currentPointY2 = currentPointY + double.parse(args[3]);
currentPointX += double.parse(args[4]);
currentPointY += double.parse(args[5]);
path!.cubicTo(
currentPointX1!,
currentPointY1!,
currentPointX2!,
currentPointY2!,
currentPointX,
currentPointY,
);
} else if (firstLetter == "S") {
if (currentPointX1 != null &&
currentPointY1 != null &&
currentPointX2 != null &&
currentPointY2 != null) {
currentPointX1 = currentPointX - currentPointX2! + currentPointX;
currentPointY1 = currentPointY - currentPointY2! + currentPointY;
currentPointX2 = double.parse(args[0]);
currentPointY2 = double.parse(args[1]);
currentPointX = double.parse(args[2]);
currentPointY = double.parse(args[3]);
path!.cubicTo(
currentPointX1!,
currentPointY1!,
currentPointX2!,
currentPointY2!,
currentPointX,
currentPointY,
);
} else {
currentPointX1 = double.parse(args[0]);
currentPointY1 = double.parse(args[1]);
currentPointX = double.parse(args[2]);
currentPointY = double.parse(args[3]);
path!.quadraticBezierTo(
currentPointX1!, currentPointY1!, currentPointX, currentPointY);
}
} else if (firstLetter == "s") {
if (currentPointX1 != null &&
currentPointY1 != null &&
currentPointX2 != null &&
currentPointY2 != null) {
currentPointX1 = currentPointX - currentPointX2! + currentPointX;
currentPointY1 = currentPointY - currentPointY2! + currentPointY;
currentPointX2 = currentPointX + double.parse(args[0]);
currentPointY2 = currentPointY + double.parse(args[1]);
currentPointX += double.parse(args[2]);
currentPointY += double.parse(args[3]);
path!.cubicTo(
currentPointX1!,
currentPointY1!,
currentPointX2!,
currentPointY2!,
currentPointX,
currentPointY,
);
} else {
currentPointX1 = currentPointX + double.parse(args[0]);
currentPointY1 = currentPointY + double.parse(args[1]);
currentPointX += double.parse(args[2]);
currentPointY += double.parse(args[3]);
path!.quadraticBezierTo(
currentPointX1!,
currentPointY1!,
currentPointX,
currentPointY,
);
}
} else if (firstLetter == "Q") {
currentPointX1 = double.parse(args[0]);
currentPointY1 = double.parse(args[1]);
currentPointX = double.parse(args[2]);
currentPointY = double.parse(args[3]);
path!.quadraticBezierTo(
currentPointX1!, currentPointY1!, currentPointX, currentPointY);
} else if (firstLetter == "q") {
currentPointX1 = currentPointX + double.parse(args[0]);
currentPointY1 = currentPointY + double.parse(args[1]);
currentPointX += double.parse(args[2]);
currentPointY += double.parse(args[3]);
path!.quadraticBezierTo(
currentPointX1!,
currentPointY1!,
currentPointX,
currentPointY,
);
} else if (firstLetter == "Z" || firstLetter == "z") {
path!.close();
}
}
videoItem.pathCache[argD] = path!;
});
return path;
}
void drawTextOnBitmap(
Canvas canvas, String imageKey, Rect frameRect, int frameAlpha) {
var dynamicText = videoItem.dynamicItem.dynamicText;
if (dynamicText.isEmpty) return;
if (dynamicText[imageKey] == null) return;
TextPainter? textPainter = dynamicText[imageKey];
textPainter?.paint(
canvas,
Offset(
(frameRect.width - textPainter.width) / 2.0,
(frameRect.height - textPainter.height) / 2.0,
),
);
}
@override
bool shouldRepaint(_SVGAPainter oldDelegate) {
if (controller._canvasNeedsClear == true) {
return true;
}
return !(oldDelegate.controller == controller &&
oldDelegate.controller.videoItem == controller.videoItem &&
oldDelegate.fit == fit &&
oldDelegate.filterQuality == filterQuality &&
oldDelegate.clipRect == clipRect);
}
}

View File

@ -0,0 +1,184 @@
import 'dart:developer';
import 'dart:ui' as ui;
import 'package:archive/archive.dart' as archive;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart' show decodeImageFromList;
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' show get;
import 'cache.dart';
import 'proto/svga.pbserver.dart';
const _filterKey = 'SVGAParser';
/// You use SVGAParser to load and decode animation files.
class SVGAParser {
const SVGAParser();
static const shared = SVGAParser();
/// Download animation file from remote server, and decode it.
/// Automatically uses cache if available and enabled.
Future<MovieEntity> decodeFromURL(String url) async {
// Try to get from cache first
final cachedBytes = await SVGACache.shared.getRawBytes(url);
if (cachedBytes != null) {
return decodeFromBuffer(cachedBytes);
}
// Download and cache
final response = await get(Uri.parse(url));
final bytes = response.bodyBytes;
// Cache the raw response bytes for future use
await SVGACache.shared.putRawBytes(url, Uint8List.fromList(bytes));
return decodeFromBuffer(bytes);
}
/// Download animation file from bundle assets, and decode it.
/// Automatically uses cache if available and enabled.
Future<MovieEntity> decodeFromAssets(String path) async {
// Try to get from cache first
final cachedBytes = await SVGACache.shared.getRawBytes('assets:$path');
if (cachedBytes != null) {
return decodeFromBuffer(cachedBytes);
}
// Load from assets and cache
final byteData = await rootBundle.load(path);
final bytes = byteData.buffer.asUint8List();
// Cache the asset bytes for future use
await SVGACache.shared.putRawBytes('assets:$path', bytes);
return decodeFromBuffer(bytes);
}
/// Download animation file from buffer, and decode it.
Future<MovieEntity> decodeFromBuffer(List<int> bytes) {
TimelineTask? timeline;
if (!kReleaseMode) {
timeline = TimelineTask(filterKey: _filterKey)
..start('DecodeFromBuffer', arguments: {'length': bytes.length});
}
final inflatedBytes = const archive.ZLibDecoder().decodeBytes(bytes);
if (timeline != null) {
timeline.instant(
'MovieEntity.fromBuffer()',
arguments: {'inflatedLength': inflatedBytes.length},
);
}
final movie = MovieEntity.fromBuffer(inflatedBytes);
if (timeline != null) {
timeline.instant(
'prepareResources()',
arguments: {'images': movie.images.keys.join(',')},
);
}
return _prepareResources(
_processShapeItems(movie),
timeline: timeline,
).whenComplete(() {
if (timeline != null) timeline.finish();
});
}
MovieEntity _processShapeItems(MovieEntity movieItem) {
for (var sprite in movieItem.sprites) {
List<ShapeEntity>? lastShape;
for (var frame in sprite.frames) {
if (frame.shapes.isNotEmpty && frame.shapes.isNotEmpty) {
if (frame.shapes[0].type == ShapeEntity_ShapeType.KEEP &&
lastShape != null) {
frame.shapes = lastShape;
} else if (frame.shapes.isNotEmpty == true) {
lastShape = frame.shapes;
}
}
}
}
return movieItem;
}
Future<MovieEntity> _prepareResources(
MovieEntity movieItem, {
TimelineTask? timeline,
}) {
final images = movieItem.images;
if (images.isEmpty) {
movieItem.releaseMemory();
return Future.value(movieItem);
}
return Future.wait(
images.entries.map((item) async {
// result null means a decoding error occurred
Uint8List data = Uint8List.fromList(item.value);
if (isMP3Data(data)) {
movieItem.audiosData[item.key] = data;
} else {
final decodeImage = await _decodeImageItem(
item.key,
data,
timeline: timeline,
);
if (decodeImage != null) {
movieItem.bitmapCache[item.key] = decodeImage;
}
}
}),
).then((_) {
// Release protobuf internal structures to reduce memory
movieItem.releaseMemory();
return movieItem;
});
}
Future<ui.Image?> _decodeImageItem(
String key,
Uint8List bytes, {
TimelineTask? timeline,
}) async {
TimelineTask? task;
if (!kReleaseMode) {
task = TimelineTask(filterKey: _filterKey, parent: timeline)
..start('DecodeImage', arguments: {'key': key, 'length': bytes.length});
}
try {
final image = await decodeImageFromList(bytes);
if (task != null) {
task.finish(arguments: {'imageSize': '${image.width}x${image.height}'});
}
return image;
} catch (e, stack) {
if (task != null) {
task.finish(arguments: {'error': '$e', 'stack': '$stack'});
}
assert(() {
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stack,
library: 'svgaplayer',
context: ErrorDescription('during prepare resource'),
informationCollector: () sync* {
yield ErrorSummary('Decoding image failed.');
},
),
);
return true;
}());
return null;
}
}
bool isMP3Data(Uint8List data) {
const mp3MagicNumber = 'ID3';
bool result = false;
if (String.fromCharCodes(data.take(mp3MagicNumber.length)) ==
mp3MagicNumber) {
result = true;
}
return result;
}
}

View File

@ -0,0 +1,295 @@
library;
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_svga/src/audio_layer.dart';
import 'package:path_drawing/path_drawing.dart';
import 'parser.dart';
import 'proto/svga.pbserver.dart';
part 'easy_player.dart';
part 'painter.dart';
class SVGAImage extends StatefulWidget {
final SVGAAnimationController _controller;
final BoxFit fit;
final bool clearsAfterStop;
/// Used to set the filterQuality of drawing the images inside SVGA.
///
/// Defaults to [FilterQuality.low]
final FilterQuality filterQuality;
/// If `true`, the SVGA painter may draw beyond the expected canvas bounds
/// and cause additional memory overhead.
///
/// For backwards compatibility, defaults to `null`,
/// which means allow drawing to overflow canvas bounds.
final bool? allowDrawingOverflow;
/// If `null`, the viewbox size of [MovieEntity] will be use.
///
/// Defaults to null.
final Size? preferredSize;
const SVGAImage(
this._controller, {
super.key,
this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low,
this.allowDrawingOverflow,
this.clearsAfterStop = true,
this.preferredSize,
});
@override
State<StatefulWidget> createState() => _SVGAImageState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Listenable>('controller', _controller));
}
}
class SVGAAnimationController extends AnimationController {
MovieEntity? _videoItem;
final List<SVGAAudioLayer> _audioLayers = [];
bool _canvasNeedsClear = false;
double _volume = 1.0;
bool _muted = false;
SVGAAnimationController({required super.vsync})
: super(duration: Duration.zero);
/// Audio volume level (0.0 to 1.0).
/// Default is 1.0 (full volume).
double get volume => _volume;
set volume(double value) {
if (_isDisposed) return;
_volume = value.clamp(0.0, 1.0);
for (final audio in _audioLayers) {
audio.setVolume(_muted ? 0.0 : _volume);
}
}
/// Whether audio is muted.
/// When muted, volume is set to 0 but the volume setting is preserved.
bool get muted => _muted;
set muted(bool value) {
if (_isDisposed) return;
_muted = value;
for (final audio in _audioLayers) {
audio.setVolume(_muted ? 0.0 : _volume);
}
}
void _disposeAudioLayers() {
if (_audioLayers.isEmpty) return;
final audioLayers = List<SVGAAudioLayer>.of(_audioLayers);
_audioLayers.clear();
for (final audio in audioLayers) {
audio.stopAudio();
unawaited(audio.dispose());
}
}
set videoItem(MovieEntity? value) {
assert(!_isDisposed, '$this has been disposed!');
if (_isDisposed) return;
if (isAnimating) {
stop();
}
_disposeAudioLayers();
if (value == null) {
clear();
}
if (_videoItem != null && _videoItem!.autorelease) {
_videoItem!.dispose();
}
_videoItem = value;
if (value != null) {
final movieParams = value.params;
assert(
movieParams.viewBoxWidth >= 0 &&
movieParams.viewBoxHeight >= 0 &&
movieParams.frames >= 1,
"Invalid SVGA file!",
);
int fps = movieParams.fps;
// avoid dividing by 0, use 20 by default
// see https://github.com/svga/SVGAPlayer-Web/blob/1c5711db068a25006316f9890b11d6666d531c39/src/videoEntity.js#L51
if (fps == 0) fps = 20;
duration = Duration(
milliseconds: (movieParams.frames / fps * 1000).toInt(),
);
for (var audio in value.audios) {
final audioLayer = SVGAAudioLayer(audio, value);
audioLayer.setVolume(_muted ? 0.0 : _volume);
_audioLayers.add(audioLayer);
}
} else {
duration = Duration.zero;
}
// reset progress after videoitem changed
reset();
}
MovieEntity? get videoItem => _videoItem;
/// Current drawing frame index of [videoItem], returns 0 if [videoItem] is null.
int get currentFrame {
final videoItem = _videoItem;
if (videoItem == null) return 0;
return min(
videoItem.params.frames - 1,
max(0, (videoItem.params.frames.toDouble() * value).toInt()),
);
}
/// Total frames of [videoItem], returns 0 if [videoItem] is null.
int get frames {
final videoItem = _videoItem;
if (videoItem == null) return 0;
return videoItem.params.frames;
}
/// mark [_SVGAPainter] needs clear
void clear() {
_canvasNeedsClear = true;
if (!_isDisposed) notifyListeners();
}
@override
TickerFuture forward({double? from}) {
assert(
_videoItem != null,
'SVGAAnimationController.forward() called after dispose()?',
);
return super.forward(from: from);
}
@override
void stop({bool canceled = true}) {
for (final audio in _audioLayers) {
audio.pauseAudio();
}
super.stop(canceled: canceled);
}
bool _isDisposed = false;
@override
void dispose() {
// auto dispose _videoItem when set null
videoItem = null;
_isDisposed = true;
super.dispose();
}
}
class _SVGAImageState extends State<SVGAImage> {
MovieEntity? video;
@override
void initState() {
super.initState();
video = widget._controller.videoItem;
widget._controller.addListener(_handleChange);
widget._controller.addStatusListener(_handleStatusChange);
}
@override
void didUpdateWidget(SVGAImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget._controller != widget._controller) {
oldWidget._controller.removeListener(_handleChange);
oldWidget._controller.removeStatusListener(_handleStatusChange);
video = widget._controller.videoItem;
widget._controller.addListener(_handleChange);
widget._controller.addStatusListener(_handleStatusChange);
}
}
void _handleChange() {
if (mounted) {
if (video == widget._controller.videoItem) {
handleAudio();
} else if (!widget._controller._isDisposed) {
setState(() {
// rebuild
video = widget._controller.videoItem;
});
}
}
}
void _handleStatusChange(AnimationStatus status) {
if (status == AnimationStatus.completed && widget.clearsAfterStop) {
widget._controller.clear();
}
}
void handleAudio() {
final controller = widget._controller;
final audioLayers = controller._audioLayers;
final effectiveVolume = controller._muted ? 0.0 : controller._volume;
for (final audio in audioLayers) {
if (!audio.isPlaying() &&
audio.audioItem.startFrame <= controller.currentFrame &&
audio.audioItem.endFrame >= controller.currentFrame) {
audio.playAudio(volume: effectiveVolume);
}
if (audio.isPlaying() &&
audio.audioItem.endFrame <= controller.currentFrame) {
audio.stopAudio();
}
}
}
@override
void dispose() {
video = null;
widget._controller.removeListener(_handleChange);
widget._controller.removeStatusListener(_handleStatusChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
final video = this.video;
final Size viewBoxSize;
if (video == null || !video.isInitialized()) {
viewBoxSize = Size.zero;
} else {
viewBoxSize = Size(video.params.viewBoxWidth, video.params.viewBoxHeight);
}
if (viewBoxSize.isEmpty) {
return const SizedBox.shrink();
}
// sugguest the size of CustomPaint
Size preferredSize = viewBoxSize;
if (widget.preferredSize != null) {
preferredSize = BoxConstraints.tight(
widget.preferredSize!,
).constrain(viewBoxSize);
}
return IgnorePointer(
child: CustomPaint(
painter: _SVGAPainter(
// _SVGAPainter will auto repaint on _controller animating
widget._controller,
fit: widget.fit,
filterQuality: widget.filterQuality,
// default is allowing overflow for backward compatibility
clipRect: widget.allowDrawingOverflow == false,
),
size: preferredSize,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
// Generated code. Do not modify.
// source: svga.proto
//
// @dart = '3.7.2'
// ignore_for_file: annotate_overrides,camel_case_types,unnecessary_const,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type,unnecessary_this,prefer_final_fields
// ignore_for_file: UNDEFINED_SHOWN_NAME
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class ShapeEntity_ShapeType extends $pb.ProtobufEnum {
static const ShapeEntity_ShapeType SHAPE = ShapeEntity_ShapeType._(
0,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'SHAPE');
static const ShapeEntity_ShapeType RECT = ShapeEntity_ShapeType._(
1,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'RECT');
static const ShapeEntity_ShapeType ELLIPSE = ShapeEntity_ShapeType._(
2,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'ELLIPSE');
static const ShapeEntity_ShapeType KEEP = ShapeEntity_ShapeType._(
3,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'KEEP');
static const $core.List<ShapeEntity_ShapeType> values =
<ShapeEntity_ShapeType>[
SHAPE,
RECT,
ELLIPSE,
KEEP,
];
static final $core.Map<$core.int, ShapeEntity_ShapeType> _byValue =
$pb.ProtobufEnum.initByValue(values);
static ShapeEntity_ShapeType? valueOf($core.int value) => _byValue[value];
const ShapeEntity_ShapeType._($core.int v, $core.String n) : super(v, n);
}
class ShapeEntity_ShapeStyle_LineCap extends $pb.ProtobufEnum {
static const ShapeEntity_ShapeStyle_LineCap LineCap_BUTT =
ShapeEntity_ShapeStyle_LineCap._(
0,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineCap_BUTT');
static const ShapeEntity_ShapeStyle_LineCap LineCap_ROUND =
ShapeEntity_ShapeStyle_LineCap._(
1,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineCap_ROUND');
static const ShapeEntity_ShapeStyle_LineCap LineCap_SQUARE =
ShapeEntity_ShapeStyle_LineCap._(
2,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineCap_SQUARE');
static const $core.List<ShapeEntity_ShapeStyle_LineCap> values =
<ShapeEntity_ShapeStyle_LineCap>[
LineCap_BUTT,
LineCap_ROUND,
LineCap_SQUARE,
];
static final $core.Map<$core.int, ShapeEntity_ShapeStyle_LineCap> _byValue =
$pb.ProtobufEnum.initByValue(values);
static ShapeEntity_ShapeStyle_LineCap? valueOf($core.int value) =>
_byValue[value];
const ShapeEntity_ShapeStyle_LineCap._($core.int v, $core.String n)
: super(v, n);
}
class ShapeEntity_ShapeStyle_LineJoin extends $pb.ProtobufEnum {
static const ShapeEntity_ShapeStyle_LineJoin LineJoin_MITER =
ShapeEntity_ShapeStyle_LineJoin._(
0,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineJoin_MITER');
static const ShapeEntity_ShapeStyle_LineJoin LineJoin_ROUND =
ShapeEntity_ShapeStyle_LineJoin._(
1,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineJoin_ROUND');
static const ShapeEntity_ShapeStyle_LineJoin LineJoin_BEVEL =
ShapeEntity_ShapeStyle_LineJoin._(
2,
const $core.bool.fromEnvironment('protobuf.omit_enum_names')
? ''
: 'LineJoin_BEVEL');
static const $core.List<ShapeEntity_ShapeStyle_LineJoin> values =
<ShapeEntity_ShapeStyle_LineJoin>[
LineJoin_MITER,
LineJoin_ROUND,
LineJoin_BEVEL,
];
static final $core.Map<$core.int, ShapeEntity_ShapeStyle_LineJoin> _byValue =
$pb.ProtobufEnum.initByValue(values);
static ShapeEntity_ShapeStyle_LineJoin? valueOf($core.int value) =>
_byValue[value];
const ShapeEntity_ShapeStyle_LineJoin._($core.int v, $core.String n)
: super(v, n);
}

View File

@ -0,0 +1,368 @@
// ignore_for_file: annotate_overrides,camel_case_types,unnecessary_const,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type,unnecessary_this,prefer_final_fields,deprecated_member_use_from_same_package, constant_identifier_names
import 'dart:core' as $core;
import 'dart:convert' as $convert;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use movieParamsDescriptor instead')
const MovieParams$json = const {
'1': 'MovieParams',
'2': const [
const {'1': 'viewBoxWidth', '3': 1, '4': 1, '5': 2, '10': 'viewBoxWidth'},
const {'1': 'viewBoxHeight', '3': 2, '4': 1, '5': 2, '10': 'viewBoxHeight'},
const {'1': 'fps', '3': 3, '4': 1, '5': 5, '10': 'fps'},
const {'1': 'frames', '3': 4, '4': 1, '5': 5, '10': 'frames'},
],
};
/// Descriptor for `MovieParams`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List movieParamsDescriptor = $convert.base64Decode(
'CgtNb3ZpZVBhcmFtcxIiCgx2aWV3Qm94V2lkdGgYASABKAJSDHZpZXdCb3hXaWR0aBIkCg12aWV3Qm94SGVpZ2h0GAIgASgCUg12aWV3Qm94SGVpZ2h0EhAKA2ZwcxgDIAEoBVIDZnBzEhYKBmZyYW1lcxgEIAEoBVIGZnJhbWVz');
@$core.Deprecated('Use spriteEntityDescriptor instead')
const SpriteEntity$json = const {
'1': 'SpriteEntity',
'2': const [
const {'1': 'imageKey', '3': 1, '4': 1, '5': 9, '10': 'imageKey'},
const {
'1': 'frames',
'3': 2,
'4': 3,
'5': 11,
'6': '.com.opensource.svga.FrameEntity',
'10': 'frames'
},
const {'1': 'matteKey', '3': 3, '4': 1, '5': 9, '10': 'matteKey'},
],
};
/// Descriptor for `SpriteEntity`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List spriteEntityDescriptor = $convert.base64Decode(
'CgxTcHJpdGVFbnRpdHkSGgoIaW1hZ2VLZXkYASABKAlSCGltYWdlS2V5EjgKBmZyYW1lcxgCIAMoCzIgLmNvbS5vcGVuc291cmNlLnN2Z2EuRnJhbWVFbnRpdHlSBmZyYW1lcxIaCghtYXR0ZUtleRgDIAEoCVIIbWF0dGVLZXk=');
@$core.Deprecated('Use audioEntityDescriptor instead')
const AudioEntity$json = const {
'1': 'AudioEntity',
'2': const [
const {'1': 'audioKey', '3': 1, '4': 1, '5': 9, '10': 'audioKey'},
const {'1': 'startFrame', '3': 2, '4': 1, '5': 5, '10': 'startFrame'},
const {'1': 'endFrame', '3': 3, '4': 1, '5': 5, '10': 'endFrame'},
const {'1': 'startTime', '3': 4, '4': 1, '5': 5, '10': 'startTime'},
const {'1': 'totalTime', '3': 5, '4': 1, '5': 5, '10': 'totalTime'},
],
};
/// Descriptor for `AudioEntity`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List audioEntityDescriptor = $convert.base64Decode(
'CgtBdWRpb0VudGl0eRIaCghhdWRpb0tleRgBIAEoCVIIYXVkaW9LZXkSHgoKc3RhcnRGcmFtZRgCIAEoBVIKc3RhcnRGcmFtZRIaCghlbmRGcmFtZRgDIAEoBVIIZW5kRnJhbWUSHAoJc3RhcnRUaW1lGAQgASgFUglzdGFydFRpbWUSHAoJdG90YWxUaW1lGAUgASgFUgl0b3RhbFRpbWU=');
@$core.Deprecated('Use layoutDescriptor instead')
const Layout$json = const {
'1': 'Layout',
'2': const [
const {'1': 'x', '3': 1, '4': 1, '5': 2, '10': 'x'},
const {'1': 'y', '3': 2, '4': 1, '5': 2, '10': 'y'},
const {'1': 'width', '3': 3, '4': 1, '5': 2, '10': 'width'},
const {'1': 'height', '3': 4, '4': 1, '5': 2, '10': 'height'},
],
};
/// Descriptor for `Layout`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List layoutDescriptor = $convert.base64Decode(
'CgZMYXlvdXQSDAoBeBgBIAEoAlIBeBIMCgF5GAIgASgCUgF5EhQKBXdpZHRoGAMgASgCUgV3aWR0aBIWCgZoZWlnaHQYBCABKAJSBmhlaWdodA==');
@$core.Deprecated('Use transformDescriptor instead')
const Transform$json = const {
'1': 'Transform',
'2': const [
const {'1': 'a', '3': 1, '4': 1, '5': 2, '10': 'a'},
const {'1': 'b', '3': 2, '4': 1, '5': 2, '10': 'b'},
const {'1': 'c', '3': 3, '4': 1, '5': 2, '10': 'c'},
const {'1': 'd', '3': 4, '4': 1, '5': 2, '10': 'd'},
const {'1': 'tx', '3': 5, '4': 1, '5': 2, '10': 'tx'},
const {'1': 'ty', '3': 6, '4': 1, '5': 2, '10': 'ty'},
],
};
/// Descriptor for `Transform`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List transformDescriptor = $convert.base64Decode(
'CglUcmFuc2Zvcm0SDAoBYRgBIAEoAlIBYRIMCgFiGAIgASgCUgFiEgwKAWMYAyABKAJSAWMSDAoBZBgEIAEoAlIBZBIOCgJ0eBgFIAEoAlICdHgSDgoCdHkYBiABKAJSAnR5');
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity$json = const {
'1': 'ShapeEntity',
'2': const [
const {
'1': 'type',
'3': 1,
'4': 1,
'5': 14,
'6': '.com.opensource.svga.ShapeEntity.ShapeType',
'10': 'type'
},
const {
'1': 'shape',
'3': 2,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.ShapeArgs',
'9': 0,
'10': 'shape'
},
const {
'1': 'rect',
'3': 3,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.RectArgs',
'9': 0,
'10': 'rect'
},
const {
'1': 'ellipse',
'3': 4,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.EllipseArgs',
'9': 0,
'10': 'ellipse'
},
const {
'1': 'styles',
'3': 10,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.ShapeStyle',
'10': 'styles'
},
const {
'1': 'transform',
'3': 11,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.Transform',
'10': 'transform'
},
],
'3': const [
ShapeEntity_ShapeArgs$json,
ShapeEntity_RectArgs$json,
ShapeEntity_EllipseArgs$json,
ShapeEntity_ShapeStyle$json
],
'4': const [ShapeEntity_ShapeType$json],
'8': const [
const {'1': 'args'},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeArgs$json = const {
'1': 'ShapeArgs',
'2': const [
const {'1': 'd', '3': 1, '4': 1, '5': 9, '10': 'd'},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_RectArgs$json = const {
'1': 'RectArgs',
'2': const [
const {'1': 'x', '3': 1, '4': 1, '5': 2, '10': 'x'},
const {'1': 'y', '3': 2, '4': 1, '5': 2, '10': 'y'},
const {'1': 'width', '3': 3, '4': 1, '5': 2, '10': 'width'},
const {'1': 'height', '3': 4, '4': 1, '5': 2, '10': 'height'},
const {'1': 'cornerRadius', '3': 5, '4': 1, '5': 2, '10': 'cornerRadius'},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_EllipseArgs$json = const {
'1': 'EllipseArgs',
'2': const [
const {'1': 'x', '3': 1, '4': 1, '5': 2, '10': 'x'},
const {'1': 'y', '3': 2, '4': 1, '5': 2, '10': 'y'},
const {'1': 'radiusX', '3': 3, '4': 1, '5': 2, '10': 'radiusX'},
const {'1': 'radiusY', '3': 4, '4': 1, '5': 2, '10': 'radiusY'},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeStyle$json = const {
'1': 'ShapeStyle',
'2': const [
const {
'1': 'fill',
'3': 1,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.ShapeStyle.RGBAColor',
'10': 'fill'
},
const {
'1': 'stroke',
'3': 2,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity.ShapeStyle.RGBAColor',
'10': 'stroke'
},
const {'1': 'strokeWidth', '3': 3, '4': 1, '5': 2, '10': 'strokeWidth'},
const {
'1': 'lineCap',
'3': 4,
'4': 1,
'5': 14,
'6': '.com.opensource.svga.ShapeEntity.ShapeStyle.LineCap',
'10': 'lineCap'
},
const {
'1': 'lineJoin',
'3': 5,
'4': 1,
'5': 14,
'6': '.com.opensource.svga.ShapeEntity.ShapeStyle.LineJoin',
'10': 'lineJoin'
},
const {'1': 'miterLimit', '3': 6, '4': 1, '5': 2, '10': 'miterLimit'},
const {'1': 'lineDashI', '3': 7, '4': 1, '5': 2, '10': 'lineDashI'},
const {'1': 'lineDashII', '3': 8, '4': 1, '5': 2, '10': 'lineDashII'},
const {'1': 'lineDashIII', '3': 9, '4': 1, '5': 2, '10': 'lineDashIII'},
],
'3': const [ShapeEntity_ShapeStyle_RGBAColor$json],
'4': const [
ShapeEntity_ShapeStyle_LineCap$json,
ShapeEntity_ShapeStyle_LineJoin$json
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeStyle_RGBAColor$json = const {
'1': 'RGBAColor',
'2': const [
const {'1': 'r', '3': 1, '4': 1, '5': 2, '10': 'r'},
const {'1': 'g', '3': 2, '4': 1, '5': 2, '10': 'g'},
const {'1': 'b', '3': 3, '4': 1, '5': 2, '10': 'b'},
const {'1': 'a', '3': 4, '4': 1, '5': 2, '10': 'a'},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeStyle_LineCap$json = const {
'1': 'LineCap',
'2': const [
const {'1': 'LineCap_BUTT', '2': 0},
const {'1': 'LineCap_ROUND', '2': 1},
const {'1': 'LineCap_SQUARE', '2': 2},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeStyle_LineJoin$json = const {
'1': 'LineJoin',
'2': const [
const {'1': 'LineJoin_MITER', '2': 0},
const {'1': 'LineJoin_ROUND', '2': 1},
const {'1': 'LineJoin_BEVEL', '2': 2},
],
};
@$core.Deprecated('Use shapeEntityDescriptor instead')
const ShapeEntity_ShapeType$json = const {
'1': 'ShapeType',
'2': const [
const {'1': 'SHAPE', '2': 0},
const {'1': 'RECT', '2': 1},
const {'1': 'ELLIPSE', '2': 2},
const {'1': 'KEEP', '2': 3},
],
};
/// Descriptor for `ShapeEntity`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List shapeEntityDescriptor = $convert.base64Decode(
'CgtTaGFwZUVudGl0eRI+CgR0eXBlGAEgASgOMiouY29tLm9wZW5zb3VyY2Uuc3ZnYS5TaGFwZUVudGl0eS5TaGFwZVR5cGVSBHR5cGUSQgoFc2hhcGUYAiABKAsyKi5jb20ub3BlbnNvdXJjZS5zdmdhLlNoYXBlRW50aXR5LlNoYXBlQXJnc0gAUgVzaGFwZRI/CgRyZWN0GAMgASgLMikuY29tLm9wZW5zb3VyY2Uuc3ZnYS5TaGFwZUVudGl0eS5SZWN0QXJnc0gAUgRyZWN0EkgKB2VsbGlwc2UYBCABKAsyLC5jb20ub3BlbnNvdXJjZS5zdmdhLlNoYXBlRW50aXR5LkVsbGlwc2VBcmdzSABSB2VsbGlwc2USQwoGc3R5bGVzGAogASgLMisuY29tLm9wZW5zb3VyY2Uuc3ZnYS5TaGFwZUVudGl0eS5TaGFwZVN0eWxlUgZzdHlsZXMSPAoJdHJhbnNmb3JtGAsgASgLMh4uY29tLm9wZW5zb3VyY2Uuc3ZnYS5UcmFuc2Zvcm1SCXRyYW5zZm9ybRoZCglTaGFwZUFyZ3MSDAoBZBgBIAEoCVIBZBp4CghSZWN0QXJncxIMCgF4GAEgASgCUgF4EgwKAXkYAiABKAJSAXkSFAoFd2lkdGgYAyABKAJSBXdpZHRoEhYKBmhlaWdodBgEIAEoAlIGaGVpZ2h0EiIKDGNvcm5lclJhZGl1cxgFIAEoAlIMY29ybmVyUmFkaXVzGl0KC0VsbGlwc2VBcmdzEgwKAXgYASABKAJSAXgSDAoBeRgCIAEoAlIBeRIYCgdyYWRpdXNYGAMgASgCUgdyYWRpdXNYEhgKB3JhZGl1c1kYBCABKAJSB3JhZGl1c1kaugUKClNoYXBlU3R5bGUSSQoEZmlsbBgBIAEoCzI1LmNvbS5vcGVuc291cmNlLnN2Z2EuU2hhcGVFbnRpdHkuU2hhcGVTdHlsZS5SR0JBQ29sb3JSBGZpbGwSTQoGc3Ryb2tlGAIgASgLMjUuY29tLm9wZW5zb3VyY2Uuc3ZnYS5TaGFwZUVudGl0eS5TaGFwZVN0eWxlLlJHQkFDb2xvclIGc3Ryb2tlEiAKC3N0cm9rZVdpZHRoGAMgASgCUgtzdHJva2VXaWR0aBJNCgdsaW5lQ2FwGAQgASgOMjMuY29tLm9wZW5zb3VyY2Uuc3ZnYS5TaGFwZUVudGl0eS5TaGFwZVN0eWxlLkxpbmVDYXBSB2xpbmVDYXASUAoIbGluZUpvaW4YBSABKA4yNC5jb20ub3BlbnNvdXJjZS5zdmdhLlNoYXBlRW50aXR5LlNoYXBlU3R5bGUuTGluZUpvaW5SCGxpbmVKb2luEh4KCm1pdGVyTGltaXQYBiABKAJSCm1pdGVyTGltaXQSHAoJbGluZURhc2hJGAcgASgCUglsaW5lRGFzaEkSHgoKbGluZURhc2hJSRgIIAEoAlIKbGluZURhc2hJSRIgCgtsaW5lRGFzaElJSRgJIAEoAlILbGluZURhc2hJSUkaQwoJUkdCQUNvbG9yEgwKAXIYASABKAJSAXISDAoBZxgCIAEoAlIBZxIMCgFiGAMgASgCUgFiEgwKAWEYBCABKAJSAWEiQgoHTGluZUNhcBIQCgxMaW5lQ2FwX0JVVFQQABIRCg1MaW5lQ2FwX1JPVU5EEAESEgoOTGluZUNhcF9TUVVBUkUQAiJGCghMaW5lSm9pbhISCg5MaW5lSm9pbl9NSVRFUhAAEhIKDkxpbmVKb2luX1JPVU5EEAESEgoOTGluZUpvaW5fQkVWRUwQAiI3CglTaGFwZVR5cGUSCQoFU0hBUEUQABIICgRSRUNUEAESCwoHRUxMSVBTRRACEggKBEtFRVAQA0IGCgRhcmdz');
@$core.Deprecated('Use frameEntityDescriptor instead')
const FrameEntity$json = const {
'1': 'FrameEntity',
'2': const [
const {'1': 'alpha', '3': 1, '4': 1, '5': 2, '10': 'alpha'},
const {
'1': 'layout',
'3': 2,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.Layout',
'10': 'layout'
},
const {
'1': 'transform',
'3': 3,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.Transform',
'10': 'transform'
},
const {'1': 'clipPath', '3': 4, '4': 1, '5': 9, '10': 'clipPath'},
const {
'1': 'shapes',
'3': 5,
'4': 3,
'5': 11,
'6': '.com.opensource.svga.ShapeEntity',
'10': 'shapes'
},
],
};
/// Descriptor for `FrameEntity`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List frameEntityDescriptor = $convert.base64Decode(
'CgtGcmFtZUVudGl0eRIUCgVhbHBoYRgBIAEoAlIFYWxwaGESMwoGbGF5b3V0GAIgASgLMhsuY29tLm9wZW5zb3VyY2Uuc3ZnYS5MYXlvdXRSBmxheW91dBI8Cgl0cmFuc2Zvcm0YAyABKAsyHi5jb20ub3BlbnNvdXJjZS5zdmdhLlRyYW5zZm9ybVIJdHJhbnNmb3JtEhoKCGNsaXBQYXRoGAQgASgJUghjbGlwUGF0aBI4CgZzaGFwZXMYBSADKAsyIC5jb20ub3BlbnNvdXJjZS5zdmdhLlNoYXBlRW50aXR5UgZzaGFwZXM=');
@$core.Deprecated('Use movieEntityDescriptor instead')
const MovieEntity$json = const {
'1': 'MovieEntity',
'2': const [
const {'1': 'version', '3': 1, '4': 1, '5': 9, '10': 'version'},
const {
'1': 'params',
'3': 2,
'4': 1,
'5': 11,
'6': '.com.opensource.svga.MovieParams',
'10': 'params'
},
const {
'1': 'images',
'3': 3,
'4': 3,
'5': 11,
'6': '.com.opensource.svga.MovieEntity.ImagesEntry',
'10': 'images'
},
const {
'1': 'sprites',
'3': 4,
'4': 3,
'5': 11,
'6': '.com.opensource.svga.SpriteEntity',
'10': 'sprites'
},
const {
'1': 'audios',
'3': 5,
'4': 3,
'5': 11,
'6': '.com.opensource.svga.AudioEntity',
'10': 'audios'
},
],
'3': const [MovieEntity_ImagesEntry$json],
};
@$core.Deprecated('Use movieEntityDescriptor instead')
const MovieEntity_ImagesEntry$json = const {
'1': 'ImagesEntry',
'2': const [
const {'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'},
const {'1': 'value', '3': 2, '4': 1, '5': 12, '10': 'value'},
],
'7': const {'7': true},
};
/// Descriptor for `MovieEntity`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List movieEntityDescriptor = $convert.base64Decode(
'CgtNb3ZpZUVudGl0eRIYCgd2ZXJzaW9uGAEgASgJUgd2ZXJzaW9uEjgKBnBhcmFtcxgCIAEoCzIgLmNvbS5vcGVuc291cmNlLnN2Z2EuTW92aWVQYXJhbXNSBnBhcmFtcxJECgZpbWFnZXMYAyADKAsyLC5jb20ub3BlbnNvdXJjZS5zdmdhLk1vdmllRW50aXR5LkltYWdlc0VudHJ5UgZpbWFnZXMSOwoHc3ByaXRlcxgEIAMoCzIhLmNvbS5vcGVuc291cmNlLnN2Z2EuU3ByaXRlRW50aXR5UgdzcHJpdGVzEjgKBmF1ZGlvcxgFIAMoCzIgLmNvbS5vcGVuc291cmNlLnN2Z2EuQXVkaW9FbnRpdHlSBmF1ZGlvcxo5CgtJbWFnZXNFbnRyeRIQCgNrZXkYASABKAlSA2tleRIUCgV2YWx1ZRgCIAEoDFIFdmFsdWU6AjgB');

View File

@ -0,0 +1,3 @@
// ignore_for_file: annotate_overrides,camel_case_types,unnecessary_const,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type,unnecessary_this,prefer_final_fields,deprecated_member_use_from_same_package
export 'svga.pb.dart';

View File

@ -0,0 +1,34 @@
name: flutter_svga
description: "Flutter SVGA player with intelligent caching, audio support, and dynamic elements. Active replacement for archived svgaplayer_flutter."
version: 0.0.13
homepage: https://github.com/5alafawyyy/flutter_svga
repository: https://github.com/5alafawyyy/flutter_svga
topics:
- animation
- svga
- vector-graphics
- lottie-alternative
- ui-effects
environment:
sdk: ^3.10.7
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
archive: ^4.0.7
http: ^1.6.0
path_drawing: ^1.0.1
protobuf: ^6.0.0
audioplayers: ^6.5.1
path_provider: ^2.1.5
crypto: ^3.0.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0

View File

@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_svga/flutter_svga.dart';
void main() {
group('flutter_svga', () {
test('SVGAAnimationController creation', () {
// Test that we can create an animation controller
// This is a basic test to ensure the package loads correctly
expect(true, isTrue);
});
test('SVGAParser shared instance', () {
// Test that SVGAParser.shared is accessible
expect(SVGAParser.shared, isNotNull);
});
test('SVGAImage widget creation', () {
// Test that SVGAImage widget can be referenced
expect(SVGAImage, isNotNull);
});
});
}

View File

@ -74,7 +74,7 @@ packages:
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
audioplayers: audioplayers:
dependency: transitive dependency: "direct main"
description: description:
name: audioplayers name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
@ -638,10 +638,9 @@ packages:
flutter_svga: flutter_svga:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_svga path: "local_packages/flutter_svga-0.0.13-patched"
sha256: c914ba2aee5e2d53775b64c7ff6530b4cdc3c27fd9348debb0d86fd68b869c39 relative: true
url: "https://pub.flutter-io.cn" source: path
source: hosted
version: "0.0.13" version: "0.0.13"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"

View File

@ -96,7 +96,8 @@ dependencies:
tancent_vap: tancent_vap:
path: ./local_packages/tancent_vap-1.0.0+1 path: ./local_packages/tancent_vap-1.0.0+1
#svga特效 #svga特效
flutter_svga: ^0.0.5 flutter_svga:
path: ./local_packages/flutter_svga-0.0.13-patched
#banner #banner
carousel_slider: ^5.1.1 carousel_slider: ^5.1.1
#谷歌支付 #谷歌支付
@ -115,6 +116,7 @@ dependencies:
#图片压缩 #图片压缩
flutter_image_compress: ^2.4.0 flutter_image_compress: ^2.4.0
extended_text: ^15.0.2 extended_text: ^15.0.2
audioplayers: ^6.5.1
#缩略图 #缩略图
video_thumbnail: ^0.5.6 video_thumbnail: ^0.5.6
badges: ^3.1.2 badges: ^3.1.2
@ -164,6 +166,7 @@ flutter:
- sc_images/daily_sign_in/ - sc_images/daily_sign_in/
- sc_images/register_reward/ - sc_images/register_reward/
- sc_images/room/ - sc_images/room/
- sc_images/room/background_examples/
- sc_images/room/emoji/ - sc_images/room/emoji/
- sc_images/room/entrance/ - sc_images/room/entrance/
- sc_images/room/anim/ - sc_images/room/anim/

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -4,6 +4,13 @@
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。 - 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
- 按桌面《Android音乐添加流程.md》在 Android 语言房内补齐本地 MP3 音乐功能,并持续记录素材接入、扫描添加、列表管理、播放器和房间播放链路进度。 - 按桌面《Android音乐添加流程.md》在 Android 语言房内补齐本地 MP3 音乐功能,并持续记录素材接入、扫描添加、列表管理、播放器和房间播放链路进度。
## 本轮个人资料页改版(分步重做)
- 已按 2026-05-06 最新反馈回到旧版个人资料页结构上分步处理,不再一次性重做整页。
- 第一步已去掉个人资料页加载阶段的骨架屏:`ref.userProfile` 或拉黑状态还没返回时,正文区域改为空占位,不再展示灰色骨架块;资料返回后仍按原来的 `Header + About/Giftwall Tab` 结构渲染。
- 第二步已将个人资料页上方默认背景图从旧的 `sc_images/person/sc_icon_my_head_bg_defalt.png` 切换为新素材 `sc_images/person/sc_icon_profile_card_default_bg.png`;用户已有可用 `backgroundPhotos` 时仍优先展示用户自定义背景。
- 第三步已调整头部资料区对齐:深绿资料区去掉顶部圆角改为矩形;头像/头像框移动到中间用户名居中单独一行ID、复制按钮、性别年龄和国旗收成同一行居中展示复制按钮点击后仍写入剪贴板并提示 copied。
- 验证进度:`dart format lib/modules/user/profile/person_detail_page.dart` 已执行;`flutter analyze --no-fatal-infos lib/modules/user/profile/person_detail_page.dart` 无 error/warning仅保留该旧文件原有的 info 级代码风格提示。下一步再继续逐项对齐资料页 UI。
## 本轮 Android 语言房音乐功能(进行中) ## 本轮 Android 语言房音乐功能(进行中)
- 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。 - 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。
- 已导入桌面 `音乐素材` 文件夹内的 12 张音乐相关 PNG并统一落到 `sc_images/room/sc_music_material_*`,当前用于房间菜单入口、播放器按钮、模式切换、音量、文件夹、更多、删除和编辑等 UI 位。 - 已导入桌面 `音乐素材` 文件夹内的 12 张音乐相关 PNG并统一落到 `sc_images/room/sc_music_material_*`,当前用于房间菜单入口、播放器按钮、模式切换、音量、文件夹、更多、删除和编辑等 UI 位。