From 5086cdd97c08ee84e63a62560f286de901b0fc18 Mon Sep 17 00:00:00 2001 From: roxy Date: Wed, 22 Apr 2026 20:08:45 +0800 Subject: [PATCH] bug fix --- assets/l10n/intl_ar.json | 19 +- assets/l10n/intl_bn.json | 7 + assets/l10n/intl_en.json | 7 + assets/l10n/intl_tr.json | 21 +- docs/voice-room-emoji-plan.md | 136 +- .../config/configs/sc_variant1_config.dart | 2 +- lib/app_localizations.dart | 15 + .../room_background_select_page.dart | 412 +++++ .../room_background_upload_page.dart | 238 +++ lib/modules/room/block/blocked_list_page.dart | 34 +- lib/modules/room/detail/room_detail_page.dart | 81 +- lib/modules/room/edit/room_edit_page.dart | 6 +- .../room/manager/room_member_page.dart | 30 +- lib/modules/room/online/room_online_page.dart | 17 +- .../room/rank/room_gift_rank_tab_page.dart | 51 +- lib/modules/room/voice_room_route.dart | 123 +- .../room_game/bridge/leader_js_bridge.dart | 275 ++++ .../data/models/room_game_models.dart | 214 ++- lib/modules/room_game/data/room_game_api.dart | 38 +- .../room_game/data/room_game_repository.dart | 86 +- .../room_game/views/baishun_game_page.dart | 38 +- .../room_game/views/leader_game_page.dart | 661 ++++++++ .../room_game/views/room_game_list_sheet.dart | 95 +- lib/modules/user/me_page2.dart | 10 +- .../user/profile/person_detail_page.dart | 12 +- lib/services/audio/rtc_manager.dart | 80 +- .../business_logic/models/res/login_res.dart | 13 + .../repositories/room_repository.dart | 7 +- .../repositories/sc_room_repository_imp.dart | 18 +- .../widgets/id/sc_special_id_badge.dart | 131 +- lib/ui_kit/widgets/room/room_head_widget.dart | 49 +- lib/ui_kit/widgets/room/room_menu_dialog.dart | 65 +- .../widgets/room/room_user_info_card.dart | 22 +- sc_images/room/sc_icon_room_background.png | Bin 0 -> 863 bytes 语言房重构.md | 1448 +++++++++++++++++ 需求进度.md | 28 +- 36 files changed, 4168 insertions(+), 321 deletions(-) create mode 100644 lib/modules/room/background/room_background_select_page.dart create mode 100644 lib/modules/room/background/room_background_upload_page.dart create mode 100644 lib/modules/room_game/bridge/leader_js_bridge.dart create mode 100644 lib/modules/room_game/views/leader_game_page.dart create mode 100644 sc_images/room/sc_icon_room_background.png create mode 100644 语言房重构.md diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index 13c202c..eb7bee2 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -8,6 +8,7 @@ "privaceyPolicy": "سياسة الخصوصية", "tips": "تنبيه", "mine": "حسابي", + "official": "رسمي", "searchNoDataTips": "أدخل معرف الغرفة أو المستخدم الذي تريد البحث عنه.", "party": "حفلة", "event": "حدث", @@ -22,10 +23,11 @@ "bag": "حقيبة", "roomEdit": "تحرير الغرفة", "cancelRoomPassword": "هل أنت متأكد أنك تريد حذف كلمة مرور الغرفة؟", - "roomMemberFee": "رسوم عضو الغرفة", - "blockedList2": "قائمة المحظورين", + "roomMemberFee": "رسوم عضو الغرفة", + "blockedList2": "قائمة المحظورين", "roomTheme2": "سمة الغرفة", - "roomPassword": "كلمة مرور الغرفة", + "background": "الخلفية", + "roomPassword": "كلمة مرور الغرفة", "numberOfMic": "عدد الميكروفونات", "pleaseEnterContent": "يرجى إدخال المحتوى", "profilePhoto": "صورة الملف الشخصي", @@ -280,6 +282,7 @@ "wealthLevel": "مستوى الثروة", "userLevel": "مستوى المستخدم", "themeGoToUploadTips": "1. ستتم مراجعة التحميل خلال 24 ساعة بعد نجاحه.\n2. ستُعاد جميع العملات إذا فشلت المراجعة.", + "pleaseUploadAccordingToExample": "يرجى الرفع وفقًا للمثال المعروض.", "goToUpload": "الانتقال للرفع", "rechargeAgency": "وكالة الشحن", "logout": "تسجيل الخروج", @@ -408,7 +411,8 @@ "refuse": "رفض", "boxContributeTips": "تم الاستثمار اليوم بالفعل، يرجى عدم الاستثمار مرة أخرى.", "help": "مساعدة", - "approved": "معتمد", + "approved": "معتمد", + "approvedWithinOneMinute": "ستتم الموافقة عليه وعرضه خلال دقيقة واحدة من الإرسال", "onlineUsers": "المستخدمون عبر الإنترنت ({1}/{2}):", "applyToJoin": "قدّم للانضمام", "hostList": "قائمة المضيف", @@ -595,7 +599,9 @@ "touristsAreNotAllowedToGoOnTheMic": "لا يُسمح للزوار بالصعود إلى الميكروفون", "superFans": "كبار المعجبين:", "special": "خاص", - "custom": "مخصص", + "custom": "مخصص", + "customBackground": "خلفية مخصصة", + "example": "مثال", "store": "المتجر", "viewFrame": "عرض الإطار", "headdress": "إطارات", @@ -624,7 +630,8 @@ "unUse": "إزالة الاستخدام", "successfulWear": "تم الارتداء بنجاح", "confirmUnUseTips": "هل تؤكد إزالة استخدامه؟", - "inUse": "قيد الاستخدام", + "inUse": "قيد الاستخدام", + "staticOrGifImage": "صورة ثابتة / GIF", "confirmUseTips": "هل تريد التأكيد على استخدامه؟", "pleaseUploadUserAvatar": "يرجى رفع صورة شخصية.", "myItems": "مقتنياتي", diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index 6906807..6f83a46 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -8,6 +8,7 @@ "privaceyPolicy": "গোপনীয়তা নীতি", "tips": "টিপস", "mine": "আমার", + "official": "অফিশিয়াল", "games": "গেমস", "party": "পার্টি", "yes": "হ্যাঁ", @@ -23,6 +24,7 @@ "roomMemberFee": "রুম সদস্য ফি", "blockedList2": "ব্লক করা তালিকা", "roomTheme2": "রুম থিম", + "background": "ব্যাকগ্রাউন্ড", "roomPassword": "রুম পাসওয়ার্ড", "numberOfMic": "মাইকের সংখ্যা", "pleaseEnterContent": "অনুগ্রহ করে বিষয়বস্তু লিখুন", @@ -231,6 +233,7 @@ "luck": "ভাগ্য", "level": "স্তর", "themeGoToUploadTips": "1.আপলোড সফল হলে 24 ঘন্টার মধ্যে রিভিউ করা হবে।\n2.রিভিউ ব্যর্থ হলে সমস্ত কয়েন ফেরত দেওয়া হবে।", + "pleaseUploadAccordingToExample": "দয়া করে দেখানো উদাহরণ অনুযায়ী আপলোড করুন।", "home": "হোম", "explore": "অন্বেষণ", "me": "আমি", @@ -283,6 +286,7 @@ "following": "ফলো করা হচ্ছে", "agent": "এজেন্ট", "approved": "অনুমোদিত", + "approvedWithinOneMinute": "সাবমিট করার ১ মিনিটের মধ্যে অনুমোদিত হয়ে প্রদর্শিত হবে", "onlineUsers": "অনলাইন ব্যবহারকারী({1}/{2}):", "applyToJoin": "যোগদানের জন্য আবেদন করুন", "supporterList": "সমর্থক তালিকা", @@ -613,6 +617,8 @@ "successfulWear": "সফলভাবে পরা হয়েছে", "confirmUnUseTips": "আপনি কি এটি খুলে ফেলতে চান?", "custom": "কাস্টম", + "customBackground": "কাস্টম ব্যাকগ্রাউন্ড", + "example": "উদাহরণ", "myItems": "আমার আইটেম", "use": "ব্যবহার করুন", "unUse": "ব্যবহার না করুন", @@ -629,6 +635,7 @@ "expired": "মেয়াদ শেষ", "day": "দিন", "inUse": "ব্যবহারে", + "staticOrGifImage": "স্ট্যাটিক ইমেজ / GIF", "confirmUseTips": "আপনি কি ব্যবহার করতে নিশ্চিত করছেন?", "pleaseUploadUserAvatar": "অনুগ্রহ করে একটি প্রোফাইল ছবি আপলোড করুন।", "joinMemberTips": "আপনি যদি রুমে পর্যটক হন, তবে আপনি মাইক্রোফোন নিতে পারবেন না।", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index ad40369..fa87ca5 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -10,6 +10,7 @@ "searchNoDataTips": "Enter the room or user ID you want to search for.", "games": "Games", "mine": "Mine", + "official": "Official", "party": "Party", "other": "Other", "yes": "Yes", @@ -22,6 +23,7 @@ "cancelRoomPassword": "Are you sure you want to delete the room password?", "roomMemberFee": "Room Member Fee", "roomTheme2": "Room Theme", + "background": "Background", "blockedList2": "Blocked List", "roomPassword": "Room Password", "numberOfMic": "Number of Mic", @@ -202,6 +204,7 @@ "luck": "Luck", "level": "Level", "themeGoToUploadTips": "1. Review will be completed within 24 hours after the upload succeeds.\n2. All coins will be returned if the review fails.", + "pleaseUploadAccordingToExample": "Please upload according to the example provided.", "home": "Home", "explore": "Explore", "me": "Me", @@ -254,6 +257,7 @@ "following": "Following", "agent": "Agency", "approved": "Approved", + "approvedWithinOneMinute": "Approved and displayed within 1 minute of submission", "onlineUsers": "Online Users({1}/{2}):", "applyToJoin": "Apply to join", "supporterList": "Supporter List", @@ -613,6 +617,8 @@ "successfulWear": "Successful wear", "confirmUnUseTips": "Do you confirm to remove it?", "custom": "Custom", + "customBackground": "Custom Background", + "example": "Example", "myItems": "My items", "use": "Use", "unUse": "Unequip", @@ -629,6 +635,7 @@ "expired": "Expired", "day": "Day", "inUse": "In use", + "staticOrGifImage": "Static image / GIF", "confirmUseTips": "Do you want to confirm using it?", "pleaseUploadUserAvatar": "Please upload an avatar.", "joinMemberTips": "If you are a visitor in the room, you cannot take the microphone.", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 2bec44f..571afd6 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -9,6 +9,7 @@ "tips": "İpuçları", "searchNoDataTips": "Aramak istediğiniz oda veya kullanıcı kimliğini girin.", "mine": "Benim", + "official": "Resmi", "party": "Parti", "myRoom": "Odam", "other": "Diğer", @@ -23,10 +24,11 @@ "aboutMe": "Hakkımda", "roomEdit": "Oda Düzenle", "cancelRoomPassword": "Oda şifresini silmek istediğinizden emin misiniz?", - "roomMemberFee": "Oda Üyelik Ücreti", - "blockedList2": "Engellenen Liste", - "roomTheme2": "Oda Teması", - "roomPassword": "Oda Şifresi", + "roomMemberFee": "Oda Üyelik Ücreti", + "blockedList2": "Engellenen Liste", + "roomTheme2": "Oda Teması", + "background": "Arka Plan", + "roomPassword": "Oda Şifresi", "noHistoricalRecordsAvailable": "Geçmiş kayıt bulunamadı.", "inviteNewUsersToEarnCoins": "Yeni kullanıcıları davet ederek coin kazanın", "crateMyRoom": "KENDİ ODANIZI OLUŞTURUN.", @@ -191,6 +193,7 @@ "luck": "Şans", "level": "Seviye", "themeGoToUploadTips": "1. Yükleme başarılı olduktan sonra 24 saat içinde inceleme yapılacaktır.\n2. İnceleme başarısız olursa tüm jettonlar iade edilecektir.", + "pleaseUploadAccordingToExample": "Lütfen verilen örneğe göre yükleyin.", "home": "Anasayfa", "explore": "Keşfet", "me": "Ben", @@ -252,7 +255,8 @@ "host": "Sunucu", "following": "Takip Edilenler", "agent": "Temsilcilik", - "approved": "Onaylandı", + "approved": "Onaylandı", + "approvedWithinOneMinute": "Gönderimden sonraki 1 dakika içinde onaylanır ve gösterilir", "onlineUsers": "Çevrimiçi Kullanıcılar({1}/{2}):", "applyToJoin": "Katılmak İçin Başvur", "supporterList": "Destekçi Listesi", @@ -612,7 +616,9 @@ "visitorList": "Ziyaretçi Listesi", "successfulWear": "Başarıyla takıldı", "confirmUnUseTips": "Kaldırmak için onaylıyor musunuz?", - "custom": "Özel", + "custom": "Özel", + "customBackground": "Özel Arka Plan", + "example": "Örnek", "myItems": "Eşyalarım", "use": "Kullan", "unUse": "Kullanmama", @@ -628,7 +634,8 @@ "successfullyUnloaded": "Başarıyla çıkarıldı", "expired": "Süresi Doldu", "day": "Gün", - "inUse": "Kullanımda", + "inUse": "Kullanımda", + "staticOrGifImage": "Statik görsel / GIF", "confirmUseTips": "Kullanmak için onaylıyor musunuz?", "pleaseUploadUserAvatar": "Lütfen bir profil fotoğrafı yükleyin.", "joinMemberTips": "Eğer odada turist iseniz, mikroyu alamazsınız.", diff --git a/docs/voice-room-emoji-plan.md b/docs/voice-room-emoji-plan.md index 4122409..412b392 100644 --- a/docs/voice-room-emoji-plan.md +++ b/docs/voice-room-emoji-plan.md @@ -8,8 +8,9 @@ Requirement summary: - Add an emoji package button next to the voice-room chat entry. - The emoji package button and text chat must open the same bottom composer sheet. -- Emoji packages are shown only on mic seats. -- Emoji packages must not appear in room chat messages, including the `All`, `Chat`, and `Gift` tabs. +- Users can send emoji packages whether they are on mic or not. +- If the sender is on mic, the emoji package is shown on the sender's mic seat and does not appear in room chat. +- If the sender is not on mic, the emoji package is shown in room chat and does not appear on any mic seat. - Wait for final backend data and UI assets before starting the real implementation. ## Current Codebase Status @@ -67,7 +68,7 @@ Recommended behavior: - Tap chat entry: open `RoomMsgInput(initialMode: text)`. - Tap emoji package button: open `RoomMsgInput(initialMode: emojiPackage)`. -- If the current user is not on mic, the emoji package button can either be disabled or show a toast such as "Take a mic first". +- The emoji package button should always be available. Do not gate it by mic status. Important layout note: @@ -89,28 +90,29 @@ Recommended send interaction for emoji package: - The sheet should stay open after sending so the user can send multiple emoji packages continuously. - If UI later wants tap-to-close, that can be a product choice, but continuous sending is the safer default for room interaction. -### 3. Seat-only display +### 3. Split display rule -Emoji package messages should not enter room chat lists. +Emoji package messages should be routed by the sender's current mic status. Recommended rule: - Text messages still follow the existing room chat path. -- Emoji package clicks trigger seat animation only. -- No new item should be inserted into `roomAllMsgList`, `roomChatMsgList`, or `roomGiftMsgList`. +- If the sender is on mic, emoji package clicks trigger seat animation only. +- If the sender is not on mic, emoji package clicks create a room chat message only. +- A single emoji package send should choose one display channel only and must not show in both places at the same time. ## Recommended Send Path ### Recommended payload shape -The smallest-change plan is to reuse the existing room custom message structure: +The smallest-change plan is still to reuse the existing room custom message structure, but make the payload support both seat display and chat display. ```dart Msg( groupId: roomAccount, type: SCRoomMsgType.emoticons, msg: emoji.sourceUrl, - number: currentSeatIndex, + number: currentSeatIndexOrMinusOne, user: currentUserProfile, role: currentUserRole, ) @@ -120,30 +122,43 @@ Recommended field meaning: - `type`: `EMOTICONS` - `msg`: selected emoji resource URL -- `number`: current seat index +- `number`: current seat index if the sender is on mic, otherwise `-1` - `user`: sender profile, for consistency with existing room messages -### Why not send it as a normal chat message +### Recommended send branching -Because `RtmProvider.addMsg(...)` always inserts the message into `roomAllMsgList` first. +The current project already has two useful building blocks: -Current receive side is already good enough: - -- In `_newGroupMsg(...)`, `EMOTICONS` goes directly to `RtcProvider.starPlayEmoji(msg)` and then `return`. -- That means remote users will not see emoji package content in room chat. - -Current sender side still needs care during implementation: - -- If the sender uses `dispatchMessage(...)` with default `addLocal: true`, the local sender will still push one item into `roomAllMsgList`. -- That would violate the requirement that emoji packages must not show in chat. +- `RtcProvider.starPlayEmoji(msg)` for seat playback +- `RtmProvider.addMsg(...)` for room chat insertion Recommended implementation behavior later: -- Build the `Msg`. -- First do a local optimistic seat display by calling `RtcProvider.starPlayEmoji(msg)`. -- Then call `dispatchMessage(msg, addLocal: false)`. +- Resolve the sender's current seat index at click time. +- If the sender is on mic: + - Build the `Msg` with the current seat index. + - First do a local optimistic seat display by calling `RtcProvider.starPlayEmoji(msg)`. + - Then call `dispatchMessage(msg, addLocal: false)`. +- If the sender is not on mic: + - Build the `Msg` with `number: -1`. + - Insert it as a local chat item. + - Then send the RTM message normally so other users also see the chat item. -This gives the sender instant seat feedback while keeping the chat list clean. +This gives the correct split behavior: + +- on mic: seat only +- off mic: chat only + +### Recommended receive branching + +The current receive logic for `EMOTICONS` should be adjusted to branch by seat index. + +Recommended behavior: + +- If `msg.number >= 0`, treat it as seat display and call `RtcProvider.starPlayEmoji(msg)`. +- If `msg.number < 0`, treat it as a normal room chat item and add it into the room chat list. + +Without this receive-side branch, the current implementation would still route all `EMOTICONS` messages to seat display only. ## Recommended Seat Resolution Rule @@ -157,11 +172,7 @@ Reason: - The user may switch mic seats after opening the popup. - Using the latest seat index avoids showing the emoji on the wrong seat. - -If the returned seat index is `-1`: - -- Do not send. -- Show a toast telling the user that only users on mic can use emoji packages. +- If the latest seat index is `-1`, that means the message should go to room chat instead of seat display. ## Backend Dependency Assessment @@ -203,14 +214,14 @@ This keeps the feature aligned with the current room event architecture. ## Optional Sync Decision -There are two possible product definitions for emoji package state: +There are now two display paths, so the sync decision should be split as well. -### Option A: transient RTM-only state +### Seat path Behavior: -- Emoji package is only an instant seat effect. -- Users who join mid-animation do not need to recover the current emoji state. +- If the sender is on mic, the emoji package is an instant seat effect. +- Users who join mid-animation do not necessarily need to recover the current emoji state. Pros: @@ -220,15 +231,9 @@ Pros: Recommended default: -- Yes. This should be the first implementation choice unless product explicitly wants state recovery. +- Yes. Treat the seat path as transient unless product explicitly wants recovery after reconnect or late join. -### Option B: recoverable seat state - -Behavior: - -- If a user joins the room late or reconnects, they can still see the currently active emoji package on the seat during its valid duration. - -If product wants this, backend and model changes are needed: +If product later wants seat-state recovery, backend and model changes are needed: - mic list response needs to include active emoji state - active emoji state should include at least resource URL and expiration timing @@ -239,7 +244,16 @@ Important current gap: - `MicRes.fromJson(...)` does not parse `emojiPath` - `MicRes.toJson()` also does not serialize `emojiPath` -So if the final backend plan depends on seat polling or room re-entry recovery, this model area will need a small follow-up patch during implementation. +### Chat path + +Behavior: + +- If the sender is not on mic, the emoji package behaves like a normal room chat message. + +Recommended default: + +- Reuse the existing room chat message list behavior and message history strategy. +- No special seat-state sync is needed for this path. ## Current Code Caveats To Remember @@ -272,6 +286,11 @@ Meaning: - The current image emoji path works because the code uses a field-reuse convention. - Later implementation should stay compatible with this path unless the seat overlay model is cleaned up in one pass. +Additional note after the new requirement: + +- The same `EMOTICONS` type now needs a second branch for off-mic chat display. +- So later implementation should not assume that every `EMOTICONS` message must call `starPlayEmoji(...)`. + ### 3. Emoji cache utility exists but is not wired into the room sheet yet `SCAppGeneralManager` already has: @@ -295,11 +314,12 @@ When backend and UI are ready, the development order should be: 1. Add the new emoji package button in `RoomBottomWidget` and adjust layout math together with `_resolveGiftCenterX(...)`. 2. Refactor `RoomMsgInput` into one shared room composer with `text` and `emojiPackage` entry modes. 3. Wire the sheet to load emoji categories and emoji items from `/material/emoji/all`. -4. Add on-mic gating and resolve seat index at click time. -5. On emoji package click, do local optimistic seat playback. -6. Send RTM `EMOTICONS` with `addLocal: false`. -7. Verify that `All`, `Chat`, and `Gift` tabs remain unchanged after sending. -8. If product wants reconnect recovery, then patch `MicRes` parsing and seat sync logic. +4. Resolve seat index at click time and branch by on-mic or off-mic state. +5. If on mic, do local optimistic seat playback and send RTM `EMOTICONS` with `addLocal: false`. +6. If off mic, insert a local chat item and send RTM `EMOTICONS` as a chat-visible message. +7. Adjust receive logic so on-mic emoji goes to seat display and off-mic emoji goes to room chat. +8. Verify that on-mic sends do not pollute chat, while off-mic sends do appear in `All` and `Chat` only. +9. If product wants reconnect recovery for seat effects, then patch `MicRes` parsing and seat sync logic. ## Acceptance Checklist @@ -307,13 +327,15 @@ When backend and UI are ready, the development order should be: - Chat entry and emoji package button open the same bottom composer sheet. - Opening from chat goes to text mode. - Opening from emoji package goes to emoji package mode. -- Only users on mic can send emoji packages. -- Sender sees the emoji package on their own seat immediately. -- Other users see the emoji package on the sender's seat. -- Emoji package sending does not create new rows in `All`, `Chat`, or `Gift`. +- Users on and off mic can both send emoji packages. +- If the sender is on mic, the sender sees the emoji package on their own seat immediately. +- If the sender is on mic, other users see the emoji package on the sender's seat. +- If the sender is on mic, the emoji package does not appear in `All` or `Chat`. +- If the sender is not on mic, the emoji package appears in `All` and `Chat`. +- If the sender is not on mic, the emoji package does not appear on any seat. - Existing text chat sending is not affected. - Existing gift floating button remains visually centered after the new button is added. -- Repeated emoji package taps queue and play correctly on the seat. +- Repeated emoji package taps queue and play correctly on the seat path. ## Final Recommendation @@ -329,7 +351,7 @@ Recommended final direction: - Reuse the existing room composer popup - Reuse `EMOTICONS` as the transport type -- Use seat-index-based RTM payload -- Do local optimistic seat playback -- Do not insert emoji package messages into any chat list -- Treat backend seat-state recovery as optional, not mandatory +- Use seat-index-based branching in the RTM payload +- On mic: do local optimistic seat playback and keep emoji out of chat +- Off mic: treat emoji as a room chat message and keep it out of seat display +- Treat backend seat-state recovery as optional for the seat path, not mandatory diff --git a/lib/app/config/configs/sc_variant1_config.dart b/lib/app/config/configs/sc_variant1_config.dart index 34dc4d6..5a1b468 100644 --- a/lib/app/config/configs/sc_variant1_config.dart +++ b/lib/app/config/configs/sc_variant1_config.dart @@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig { @override String get apiHost => const String.fromEnvironment( 'API_HOST', - defaultValue: 'https://jvapi.haiyihy.com/', + defaultValue: 'http://192.168.110.43:1100/', ); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 @override diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index d554edb..e8bdb9e 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -68,6 +68,8 @@ class SCAppLocalizations { String get expirationTime => translate('expirationTime'); + String get official => translate('official'); + String get inviteNewUsersToEarnCoins => translate('inviteNewUsersToEarnCoins'); @@ -104,6 +106,8 @@ class SCAppLocalizations { String get roomTheme2 => translate('roomTheme2'); + String get background => translate('background'); + String get blockedList2 => translate('blockedList2'); String get crateMyRoom => translate('crateMyRoom'); @@ -510,6 +514,8 @@ class SCAppLocalizations { String get approved => translate('approved'); + String get approvedWithinOneMinute => translate('approvedWithinOneMinute'); + String get restorePurchasesTips => translate('restorePurchasesTips'); String get rejected => translate('rejected'); @@ -1212,6 +1218,9 @@ class SCAppLocalizations { String get themeGoToUploadTips => translate('themeGoToUploadTips'); + String get pleaseUploadAccordingToExample => + translate('pleaseUploadAccordingToExample'); + String get myItems => translate('myItems'); String get items => translate('items'); @@ -1307,6 +1316,10 @@ class SCAppLocalizations { String get custom => translate('custom'); + String get customBackground => translate('customBackground'); + + String get example => translate('example'); + String get touristsCannotSendMessages => translate('touristsCannotSendMessages'); @@ -1450,6 +1463,8 @@ class SCAppLocalizations { String get mine => translate('mine'); + String get staticOrGifImage => translate('staticOrGifImage'); + String get party => translate('party'); String get event => translate('event'); diff --git a/lib/modules/room/background/room_background_select_page.dart b/lib/modules/room/background/room_background_select_page.dart new file mode 100644 index 0000000..97b5ff9 --- /dev/null +++ b/lib/modules/room/background/room_background_select_page.dart @@ -0,0 +1,412 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/modules/room/voice_room_route.dart'; +import 'package:yumi/shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart'; +import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; +import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; + +class RoomBackgroundSelectPage extends StatefulWidget { + const RoomBackgroundSelectPage({super.key}); + + @override + State createState() => + _RoomBackgroundSelectPageState(); +} + +class _RoomBackgroundSelectPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final List<_RoomBackgroundItem> _officialItems; + late final List<_RoomBackgroundItem> _mineItems; + + bool get _showAddButton => _tabController.index == 1; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_handleTabChange); + _officialItems = [ + _RoomBackgroundItem(inUse: true), + _RoomBackgroundItem(), + _RoomBackgroundItem(), + _RoomBackgroundItem(), + ]; + _mineItems = [_RoomBackgroundItem(), _RoomBackgroundItem()]; + } + + @override + void dispose() { + _tabController.removeListener(_handleTabChange); + _tabController.dispose(); + super.dispose(); + } + + void _handleTabChange() { + if (!mounted) { + return; + } + setState(() {}); + } + + Future _openUploadPage() async { + final uploadedPath = await VoiceRoomRoute.openRoomBackgroundUpload( + context, + ); + if (!mounted || (uploadedPath ?? "").trim().isEmpty) { + return; + } + + setState(() { + for (final item in _mineItems) { + item.inUse = false; + } + _mineItems.insert( + 0, + _RoomBackgroundItem(localImagePath: uploadedPath, inUse: true), + ); + }); + _tabController.animateTo(1); + } + + void _selectItem(List<_RoomBackgroundItem> items, int index) { + setState(() { + for (final item in items) { + item.inUse = false; + } + items[index].inUse = true; + }); + } + + @override + Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context)!; + final bottomPadding = MediaQuery.of(context).padding.bottom; + return Stack( + children: [ + Image.asset( + SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(), + width: ScreenUtil().screenWidth, + height: ScreenUtil().screenHeight, + fit: BoxFit.fill, + ), + Scaffold( + backgroundColor: Colors.transparent, + appBar: _buildAppBar(context, localizations), + body: Container( + color: const Color(0xff0F0F0F), + child: Stack( + children: [ + Column( + children: [ + Container( + height: 0.5, + color: Colors.white.withValues(alpha: 0.15), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _RoomBackgroundGrid( + items: _officialItems, + onTap: + (index) => _selectItem(_officialItems, index), + bottomPadding: 24.w, + ), + _RoomBackgroundGrid( + items: _mineItems, + onTap: (index) => _selectItem(_mineItems, index), + bottomPadding: 100.w, + ), + ], + ), + ), + ], + ), + if (_showAddButton) + Positioned( + left: 18.w, + right: 18.w, + bottom: 20.w + bottomPadding, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openUploadPage, + child: Container( + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.w), + gradient: LinearGradient( + colors: [ + SocialChatTheme.primaryLight, + const Color(0xff8BF2D0), + ], + ), + ), + child: Text( + 'add', + style: TextStyle( + color: const Color(0xff0B2823), + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + PreferredSizeWidget _buildAppBar( + BuildContext context, + SCAppLocalizations localizations, + ) { + return SocialChatAppBar( + backgroundColor: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 52.w, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.pop(context), + child: Icon( + SCGlobalConfig.lang == "ar" + ? Icons.keyboard_arrow_right + : Icons.keyboard_arrow_left, + size: 28.w, + color: Colors.white, + ), + ), + ), + Expanded( + child: Center( + child: SizedBox( + width: 180.w, + child: TabBar( + controller: _tabController, + labelColor: Colors.white, + unselectedLabelColor: Colors.white54, + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + labelStyle: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + indicator: SCFixedWidthTabIndicator( + width: 18.w, + color: SocialChatTheme.primaryLight, + height: 3.w, + borderRadius: 2.w, + ), + dividerColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + tabs: [ + Tab(text: localizations.official), + Tab(text: localizations.mine), + ], + ), + ), + ), + ), + SizedBox( + width: 72.w, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openUploadPage, + child: Align( + alignment: Alignment.center, + child: Text( + localizations.custom, + style: TextStyle( + color: SocialChatTheme.primaryLight, + fontSize: 15.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _RoomBackgroundGrid extends StatelessWidget { + const _RoomBackgroundGrid({ + required this.items, + required this.onTap, + required this.bottomPadding, + }); + + final List<_RoomBackgroundItem> items; + final ValueChanged onTap; + final double bottomPadding; + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: EdgeInsets.fromLTRB(16.w, 16.w, 16.w, bottomPadding), + itemCount: items.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 14.w, + crossAxisSpacing: 14.w, + childAspectRatio: 0.64, + ), + itemBuilder: (context, index) { + return _RoomBackgroundCard( + item: items[index], + onTap: () => onTap(index), + ); + }, + ); + } +} + +class _RoomBackgroundCard extends StatelessWidget { + const _RoomBackgroundCard({required this.item, required this.onTap}); + + final _RoomBackgroundItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context)!; + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: const Color(0xff18F2B1).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12.w), + border: Border.all( + color: + item.inUse + ? SocialChatTheme.primaryLight + : Colors.white.withValues(alpha: 0.08), + width: 1.w, + ), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(10.w), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.w), + child: _buildPreview(context), + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 12.w), + child: Text( + localizations.staticOrGifImage, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 13.sp, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + if (item.inUse) + PositionedDirectional( + top: 10.w, + start: 10.w, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.w), + decoration: BoxDecoration( + color: SocialChatTheme.primaryLight, + borderRadius: BorderRadius.circular(999.w), + ), + child: Text( + localizations.inUse, + style: TextStyle( + color: const Color(0xff09372E), + fontSize: 11.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPreview(BuildContext context) { + final localizations = SCAppLocalizations.of(context)!; + if ((item.localImagePath ?? "").isNotEmpty) { + final file = File(item.localImagePath!); + if (file.existsSync()) { + return Image.file(file, fit: BoxFit.cover); + } + } + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [const Color(0xff0F4C40), const Color(0xff0B2823)], + ), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.image_outlined, + size: 34.w, + color: Colors.white.withValues(alpha: 0.45), + ), + ), + Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Text( + localizations.staticOrGifImage, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 12.sp, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _RoomBackgroundItem { + _RoomBackgroundItem({this.localImagePath, this.inUse = false}); + + final String? localImagePath; + bool inUse; +} diff --git a/lib/modules/room/background/room_background_upload_page.dart b/lib/modules/room/background/room_background_upload_page.dart new file mode 100644 index 0000000..5298037 --- /dev/null +++ b/lib/modules/room/background/room_background_upload_page.dart @@ -0,0 +1,238 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; +import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; + +class RoomBackgroundUploadPage extends StatefulWidget { + const RoomBackgroundUploadPage({super.key}); + + @override + State createState() => + _RoomBackgroundUploadPageState(); +} + +class _RoomBackgroundUploadPageState extends State { + final ImagePicker _picker = ImagePicker(); + File? _selectedImage; + + Future _pickImage() async { + final pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 90, + maxWidth: 1920, + maxHeight: 1080, + ); + if (pickedFile == null || !mounted) { + return; + } + + setState(() { + _selectedImage = File(pickedFile.path); + }); + } + + @override + Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context)!; + return Stack( + children: [ + Image.asset( + SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(), + width: ScreenUtil().screenWidth, + height: ScreenUtil().screenHeight, + fit: BoxFit.fill, + ), + Scaffold( + backgroundColor: Colors.transparent, + appBar: SocialChatStandardAppBar( + title: localizations.customBackground, + actions: const [], + backButtonColor: Colors.white, + backgroundColor: Colors.transparent, + ), + body: Container( + color: const Color(0xff0F0F0F), + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(18.w, 20.w, 18.w, 28.w), + child: Column( + children: [ + GestureDetector( + onTap: _pickImage, + child: Container( + width: 132.w, + height: 170.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.w), + color: const Color(0xff143B34), + border: Border.all( + color: SocialChatTheme.primaryLight, + width: 1.w, + ), + ), + child: + _selectedImage != null + ? ClipRRect( + borderRadius: BorderRadius.circular(14.w), + child: Image.file( + _selectedImage!, + fit: BoxFit.cover, + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.file_upload_outlined, + color: Colors.white, + size: 28.w, + ), + SizedBox(height: 10.w), + Text( + localizations.goToUpload, + style: TextStyle( + color: Colors.white, + fontSize: 13.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + SizedBox(height: 16.w), + Text( + localizations.pleaseUploadAccordingToExample, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white54, + fontSize: 13.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 20.w), + Text( + localizations.example, + style: TextStyle( + color: SocialChatTheme.primaryLight, + fontSize: 18.sp, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 16.w), + Row( + children: [ + Expanded(child: _RoomBackgroundExampleCard()), + SizedBox(width: 16.w), + Expanded(child: _RoomBackgroundExampleCard()), + ], + ), + SizedBox(height: 16.w), + Text( + localizations.approvedWithinOneMinute, + style: TextStyle( + color: const Color(0xffF26F6F), + fontSize: 13.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 28.w), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: + _selectedImage == null + ? null + : () => SCNavigatorUtils.goBackWithParams( + context, + _selectedImage!.path, + ), + child: Opacity( + opacity: _selectedImage == null ? 0.45 : 1, + child: Container( + height: 48.w, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.w), + gradient: LinearGradient( + colors: [ + SocialChatTheme.primaryLight, + const Color(0xff8BF2D0), + ], + ), + ), + child: Text( + localizations.save, + style: TextStyle( + color: const Color(0xff0B2823), + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _RoomBackgroundExampleCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context)!; + return Container( + height: 300.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.w), + color: const Color(0xff143B34), + ), + child: Stack( + children: [ + Positioned.fill( + child: Icon( + Icons.image_outlined, + size: 42.w, + color: Colors.white.withValues(alpha: 0.12), + ), + ), + Positioned( + left: 12.w, + right: 12.w, + bottom: 18.w, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.w), + border: Border.all( + color: SocialChatTheme.primaryLight, + width: 1.w, + ), + color: const Color(0xff1B4A40), + ), + child: Text( + localizations.staticOrGifImage, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 11.sp, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/room/block/blocked_list_page.dart b/lib/modules/room/block/blocked_list_page.dart index 7bdb224..fbc644c 100644 --- a/lib/modules/room/block/blocked_list_page.dart +++ b/lib/modules/room/block/blocked_list_page.dart @@ -13,6 +13,7 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/business_logic/models/res/room_black_list_res.dart'; import 'package:yumi/modules/index/main_route.dart'; +import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart'; ///房间用户 黑名单 class BlockedListPage extends SCPageList { @@ -41,7 +42,7 @@ class _BlockedListPageState @override Widget build(BuildContext context) { - return SafeArea( + return SafeArea( child: ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(12.0), @@ -111,10 +112,28 @@ class _BlockedListPageState ), ), SizedBox(height: 4.w), - text( - "ID:${res.blacklistUserProfile?.getID()}", - fontSize: 10.sp, - textColor: Colors.white, + SCSpecialIdBadge( + idText: res.blacklistUserProfile?.getID() ?? "", + showAnimated: + res.blacklistUserProfile?.hasSpecialId() ?? false, + assetPath: SCSpecialIdAssets.userId, + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + res.blacklistUserProfile + ?.shouldShowColoredSpecialIdText() ?? + false, + animationTextStyle: TextStyle( + color: Colors.white, + fontSize: 10.sp, + ), + normalTextStyle: TextStyle( + color: Colors.white, + fontSize: 10.sp, + ), + animationFit: BoxFit.contain, ), ], ), @@ -151,7 +170,10 @@ class _BlockedListPageState Function? onErr, }) async { try { - var roomList = await SCAccountRepository().roomBlacklist(roomId ?? "", "0"); + var roomList = await SCAccountRepository().roomBlacklist( + roomId ?? "", + "0", + ); onSuccess(roomList); } catch (e) { if (onErr != null) { diff --git a/lib/modules/room/detail/room_detail_page.dart b/lib/modules/room/detail/room_detail_page.dart index b0e4c3a..bf446fa 100644 --- a/lib/modules/room/detail/room_detail_page.dart +++ b/lib/modules/room/detail/room_detail_page.dart @@ -163,32 +163,35 @@ class _RoomDetailPageState extends State { ?.roomAccount ?? "", showAnimated: - (ref - .currenRoom - ?.roomProfile - ?.roomProfile - ?.roomAccount - ?.isNotEmpty ?? - false), + ref + .currenRoom + ?.roomProfile + ?.userProfile + ?.hasSpecialId() ?? + false, assetPath: SCSpecialIdAssets.roomId, - animationWidth: 124.w, - animationHeight: 30.w, - textPadding: EdgeInsets.fromLTRB( - 36.w, - 7.w, - 18.w, - 7.w, - ), + animationWidth: 66.w, + animationHeight: 26.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + ref + .currenRoom + ?.roomProfile + ?.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( - color: Colors.white, + color: Colors.black, fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), normalTextStyle: TextStyle( color: Colors.black, fontSize: 12.sp, ), + animationFit: BoxFit.contain, ), SizedBox(width: 3.w), GestureDetector( @@ -231,10 +234,9 @@ class _RoomDetailPageState extends State { if (!widget.isHomeowner) { return; } - SCNavigatorUtils.push( + VoiceRoomRoute.openRoomEdit( context, - "${VoiceRoomRoute.roomEdit}?need=false", - replace: false, + needRestCurrentRoomInfo: false, ); }, ), @@ -328,26 +330,31 @@ class _RoomDetailPageState extends State { assetPath: SCSpecialIdAssets .roomOwnerId, - animationWidth: 132.w, - animationHeight: 32.w, - textPadding: - EdgeInsets.fromLTRB( - 40.w, - 8.w, - 18.w, - 8.w, - ), + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: + true, + animatedTextSpacing: 0, + showAnimatedGradientText: + ref + .currenRoom + ?.roomProfile + ?.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( - color: Colors.white, - fontSize: 13.sp, + color: Colors.black, + fontSize: 14.sp, fontWeight: - FontWeight.w700, + FontWeight.w600, ), normalTextStyle: TextStyle( color: Colors.black, fontSize: 14.sp, ), + animationFit: + BoxFit.contain, ), SizedBox(width: 3.w), GestureDetector( @@ -456,10 +463,9 @@ class _RoomDetailPageState extends State { if (!widget.isHomeowner) { return; } - SCNavigatorUtils.push( + VoiceRoomRoute.openRoomEdit( context, - "${VoiceRoomRoute.roomEdit}?need=false", - replace: false, + needRestCurrentRoomInfo: false, ); }, ), @@ -552,10 +558,9 @@ class _RoomDetailPageState extends State { ), ), onTap: () { - SCNavigatorUtils.push( + VoiceRoomRoute.openRoomEdit( context, - "${VoiceRoomRoute.roomEdit}?need=false", - replace: false, + needRestCurrentRoomInfo: false, ); }, ), diff --git a/lib/modules/room/edit/room_edit_page.dart b/lib/modules/room/edit/room_edit_page.dart index a4bfee0..702ec45 100644 --- a/lib/modules/room/edit/room_edit_page.dart +++ b/lib/modules/room/edit/room_edit_page.dart @@ -412,11 +412,7 @@ class _RoomEditPageState extends State { ], ), onTap: () { - SCNavigatorUtils.push( - context, - VoiceRoomRoute.roomTheme, - replace: false, - ); + VoiceRoomRoute.openRoomTheme(context); }, ), SizedBox(height: 12.w), diff --git a/lib/modules/room/manager/room_member_page.dart b/lib/modules/room/manager/room_member_page.dart index 9b8f493..7f99ba8 100644 --- a/lib/modules/room/manager/room_member_page.dart +++ b/lib/modules/room/manager/room_member_page.dart @@ -21,6 +21,7 @@ import '../../../ui_kit/components/sc_debounce_widget.dart'; import '../../../ui_kit/components/sc_page_list.dart'; import '../../../ui_kit/components/sc_tts.dart'; import '../../../ui_kit/components/text/sc_text.dart'; +import '../../../ui_kit/widgets/id/sc_special_id_badge.dart'; ///房间成员列表 class RoomMemberPage extends SCPageList { @@ -195,11 +196,30 @@ class _RoomMemberPageState ), ), SizedBox(height: 3.w), - text( - "ID:${userInfo.userProfile?.getID()}", - fontSize: 12.sp, - textColor: Colors.black, - fontWeight: FontWeight.bold, + SCSpecialIdBadge( + idText: userInfo.userProfile?.getID() ?? "", + showAnimated: + userInfo.userProfile?.hasSpecialId() ?? false, + assetPath: SCSpecialIdAssets.userId, + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + userInfo.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, + animationTextStyle: TextStyle( + color: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + normalTextStyle: TextStyle( + color: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + animationFit: BoxFit.contain, ), ], ), diff --git a/lib/modules/room/online/room_online_page.dart b/lib/modules/room/online/room_online_page.dart index 4372144..3788ece 100644 --- a/lib/modules/room/online/room_online_page.dart +++ b/lib/modules/room/online/room_online_page.dart @@ -153,11 +153,14 @@ class _RoomOnlinePageState idText: userInfo.getID(), showAnimated: userInfo.hasSpecialId(), assetPath: SCSpecialIdAssets.userId, - animationWidth: 110.w, - animationHeight: 28.w, - textPadding: EdgeInsets.fromLTRB(33.w, 6.w, 16.w, 6.w), + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + userInfo.shouldShowColoredSpecialIdText(), animationTextStyle: TextStyle( - color: Colors.white, + color: Colors.black, fontSize: 12.sp, fontWeight: FontWeight.bold, ), @@ -167,14 +170,14 @@ class _RoomOnlinePageState fontWeight: FontWeight.bold, ), showCopyIcon: true, + showCopyIconWhenAnimated: true, copyIconSize: 12.w, copyIconSpacing: 5.w, + animationFit: BoxFit.contain, ), ), onTap: () { - Clipboard.setData( - ClipboardData(text: userInfo.getID() ?? ""), - ); + Clipboard.setData(ClipboardData(text: userInfo.getID())); SCTts.show( SCAppLocalizations.of(context)!.copiedToClipboard, ); diff --git a/lib/modules/room/rank/room_gift_rank_tab_page.dart b/lib/modules/room/rank/room_gift_rank_tab_page.dart index 54c48dc..2c03940 100644 --- a/lib/modules/room/rank/room_gift_rank_tab_page.dart +++ b/lib/modules/room/rank/room_gift_rank_tab_page.dart @@ -13,6 +13,7 @@ import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/shared/business_logic/models/res/room_gift_rank_res.dart'; +import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart'; import 'package:yumi/ui_kit/widgets/room/room_user_info_card.dart'; class RoomGiftRankTabPage extends SCPageList { @@ -113,7 +114,12 @@ class _RoomGiftRankTabPageState fontSize: 14.sp, type: userInfo.userProfile?.getVIP()?.name ?? "", needScroll: - (userInfo.userProfile?.userNickname?.characters.length ?? 0) > + (userInfo + .userProfile + ?.userNickname + ?.characters + .length ?? + 0) > 14, ), ], @@ -124,17 +130,34 @@ class _RoomGiftRankTabPageState child: Row( textDirection: TextDirection.ltr, children: [ - text( - "ID:${userInfo.userProfile?.getID()}", - fontSize: 12.sp, - textColor: Colors.white, - fontWeight: FontWeight.bold, - ), - SizedBox(width: 5.w), - Image.asset( - "sc_images/room/sc_icon_user_card_copy_id.png", - width: 12.w, - height: 12.w, + SCSpecialIdBadge( + idText: userInfo.userProfile?.getID() ?? "", + showAnimated: + userInfo.userProfile?.hasSpecialId() ?? false, + assetPath: SCSpecialIdAssets.userId, + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + userInfo.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, + animationTextStyle: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + normalTextStyle: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + showCopyIcon: true, + showCopyIconWhenAnimated: true, + copyIconSize: 12.w, + copyIconSpacing: 5.w, + animationFit: BoxFit.contain, ), SizedBox(width: 8.w), ], @@ -144,7 +167,9 @@ class _RoomGiftRankTabPageState Clipboard.setData( ClipboardData(text: userInfo.userProfile?.getID() ?? ""), ); - SCTts.show(SCAppLocalizations.of(context)!.copiedToClipboard); + SCTts.show( + SCAppLocalizations.of(context)!.copiedToClipboard, + ); }, ), ], diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart index 12f2f5e..c32720f 100644 --- a/lib/modules/room/voice_room_route.dart +++ b/lib/modules/room/voice_room_route.dart @@ -1,20 +1,103 @@ -import 'package:fluro/fluro.dart'; -import 'package:yumi/modules/room/edit/room_edit_page.dart'; -import 'package:yumi/modules/room/them/room_theme_page.dart'; -import 'package:yumi/modules/room/voice_room_page.dart'; -import 'package:yumi/app/routes/sc_router_init.dart'; -class VoiceRoomRoute implements SCIRouterProvider { - static String voiceRoom = '/room'; - static String roomEdit = '/room/roomEdit'; - static String roomTheme = '/room/roomTheme'; - - @override - void initRouter(FluroRouter router) { - router.define(voiceRoom, - handler: Handler(handlerFunc: (_, params) => VoiceRoomPage())); - router.define(roomTheme, - handler: Handler(handlerFunc: (_, params) => RoomThemePage())); - router.define(roomEdit, - handler: Handler(handlerFunc: (_, params) => RoomEditPage(needRestCurrentRoomInfo: params['need']!.first))); - } -} +import 'dart:io'; + +import 'package:fluro/fluro.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.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/edit/room_edit_page.dart'; +import 'package:yumi/modules/room/them/room_theme_page.dart'; +import 'package:yumi/modules/room/voice_room_page.dart'; +import 'package:yumi/app/routes/sc_router_init.dart'; + +class VoiceRoomRoute implements SCIRouterProvider { + static String voiceRoom = '/room'; + static String roomEdit = '/room/roomEdit'; + static String roomTheme = '/room/roomTheme'; + static String roomBackgroundSelect = '/room/background/select'; + static String roomBackgroundUpload = '/room/background/upload'; + + static Route _buildRoute(Widget page) { + if (Platform.isIOS) { + return CupertinoPageRoute(builder: (_) => page); + } + return MaterialPageRoute(builder: (_) => page); + } + + static Future openRoomEdit( + BuildContext context, { + required bool needRestCurrentRoomInfo, + bool rootNavigator = true, + }) { + return Navigator.of(context, rootNavigator: rootNavigator).push( + _buildRoute( + RoomEditPage( + needRestCurrentRoomInfo: needRestCurrentRoomInfo ? "true" : "false", + ), + ), + ); + } + + static Future openRoomTheme( + BuildContext context, { + bool rootNavigator = false, + }) { + return Navigator.of( + context, + rootNavigator: rootNavigator, + ).push(_buildRoute(RoomThemePage())); + } + + static Future openRoomBackgroundSelect( + BuildContext context, { + bool rootNavigator = false, + }) { + return Navigator.of( + context, + rootNavigator: rootNavigator, + ).push(_buildRoute(const RoomBackgroundSelectPage())); + } + + static Future openRoomBackgroundUpload( + BuildContext context, { + bool rootNavigator = false, + }) { + return Navigator.of( + context, + rootNavigator: rootNavigator, + ).push(_buildRoute(const RoomBackgroundUploadPage())); + } + + @override + void initRouter(FluroRouter router) { + router.define( + voiceRoom, + handler: Handler(handlerFunc: (_, params) => VoiceRoomPage()), + ); + router.define( + roomTheme, + handler: Handler(handlerFunc: (_, params) => RoomThemePage()), + ); + router.define( + roomBackgroundSelect, + handler: Handler( + handlerFunc: (_, params) => const RoomBackgroundSelectPage(), + ), + ); + router.define( + roomBackgroundUpload, + handler: Handler( + handlerFunc: (_, params) => const RoomBackgroundUploadPage(), + ), + ); + router.define( + roomEdit, + handler: Handler( + handlerFunc: + (_, params) => RoomEditPage( + needRestCurrentRoomInfo: params['need']?.first ?? "false", + ), + ), + ); + } +} diff --git a/lib/modules/room_game/bridge/leader_js_bridge.dart b/lib/modules/room_game/bridge/leader_js_bridge.dart new file mode 100644 index 0000000..23effc1 --- /dev/null +++ b/lib/modules/room_game/bridge/leader_js_bridge.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; + +class LeaderBridgeActions { + static const String getConfig = 'getConfig'; + static const String pay = 'pay'; + static const String closeGame = 'closeGame'; + static const String loadComplete = 'loadComplete'; + static const String debugLog = 'debugLog'; +} + +class LeaderBridgeMessage { + const LeaderBridgeMessage({ + required this.action, + this.payload = const {}, + }); + + final String action; + final Map payload; + + static LeaderBridgeMessage fromAction(String action, String rawMessage) { + return LeaderBridgeMessage( + action: action, + payload: _parsePayload(rawMessage), + ); + } + + static LeaderBridgeMessage parse(String rawMessage) { + try { + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is Map) { + return LeaderBridgeMessage( + action: + (decoded['action'] ?? decoded['cmd'] ?? decoded['event'] ?? '') + .toString() + .trim(), + payload: _parsePayload( + decoded['payload'] ?? decoded['data'] ?? decoded['params'], + ), + ); + } + } catch (_) {} + return LeaderBridgeMessage(action: rawMessage.trim()); + } + + static Map _parsePayload(dynamic rawPayload) { + if (rawPayload is Map) { + return rawPayload; + } + if (rawPayload is String) { + final trimmed = rawPayload.trim(); + if (trimmed.isEmpty) { + return const {}; + } + try { + final dynamic decoded = jsonDecode(trimmed); + if (decoded is Map) { + return decoded; + } + } catch (_) {} + return {'raw': trimmed}; + } + return const {}; + } +} + +class LeaderJsBridge { + static const String channelName = 'LeaderBridgeChannel'; + + static String bootstrapScript({Map? launchPayload}) { + final safeLaunchPayload = jsonEncode( + launchPayload ?? const {}, + ); + return ''' + (function() { + const launchPayload = $safeLaunchPayload; + + function toPayload(input) { + if (typeof input === 'string') { + try { + return JSON.parse(input); + } catch (_) { + return input.trim() ? { raw: input.trim() } : {}; + } + } + if (input && typeof input === 'object') { + return input; + } + return {}; + } + + function stringifyForLog(value) { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value.length > 320 ? value.slice(0, 320) + '...' : value; + } + try { + const text = JSON.stringify(value); + return text.length > 320 ? text.slice(0, 320) + '...' : text; + } catch (_) { + return String(value); + } + } + + function postMessage(action, input) { + const payload = toPayload(input); + $channelName.postMessage(JSON.stringify({ + action: action, + payload: payload + })); + return payload; + } + + function postDebug(tag, payload) { + postMessage('${LeaderBridgeActions.debugLog}', { + tag: tag, + message: stringifyForLog(payload) + }); + } + + function invokeCallback(callback, payload) { + if (typeof callback === 'function') { + callback(payload); + return true; + } + return false; + } + + function dispatchConfigReady(payload) { + if (typeof window.onLeaderLaunchConfig === 'function') { + window.onLeaderLaunchConfig(payload); + } + if (typeof window.onLaunchConfig === 'function') { + window.onLaunchConfig(payload.launchConfig || {}, payload); + } + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + window.dispatchEvent( + new CustomEvent('leaderLaunchConfig', { detail: payload }) + ); + window.dispatchEvent( + new CustomEvent('launchConfig', { + detail: payload.launchConfig || {} + }) + ); + } + if (typeof document !== 'undefined' && + typeof document.dispatchEvent === 'function' && + typeof CustomEvent === 'function') { + document.dispatchEvent( + new CustomEvent('leaderLaunchConfig', { detail: payload }) + ); + document.dispatchEvent( + new CustomEvent('launchConfig', { + detail: payload.launchConfig || {} + }) + ); + } + } + + function applyLaunchPayload(payload) { + const normalizedPayload = + payload && typeof payload === 'object' ? payload : {}; + const payloadText = JSON.stringify(normalizedPayload); + const payloadChanged = window.__leaderLaunchPayloadText !== payloadText; + const launchConfig = + normalizedPayload.launchConfig && + typeof normalizedPayload.launchConfig === 'object' + ? normalizedPayload.launchConfig + : {}; + const entry = + normalizedPayload.entry && typeof normalizedPayload.entry === 'object' + ? normalizedPayload.entry + : {}; + window.__leaderLaunchPayload = normalizedPayload; + window.__leaderLaunchPayloadText = payloadText; + window.leaderLaunchPayload = normalizedPayload; + window.__leaderLaunchConfig = launchConfig; + window.leaderLaunchConfig = launchConfig; + window.__leaderLaunchEntry = entry; + window.leaderLaunchEntry = entry; + window.NativeBridge = window.NativeBridge || {}; + window.NativeBridge.config = launchConfig; + window.NativeBridge.launchConfig = launchConfig; + window.NativeBridge.entry = entry; + window.NativeBridge.getConfig = function(callback) { + invokeCallback(callback, launchConfig); + return launchConfig; + }; + window.NativeBridge.getLaunchPayload = function(callback) { + invokeCallback(callback, normalizedPayload); + return normalizedPayload; + }; + window.getConfig = function(callback) { + return window.NativeBridge.getConfig(callback); + }; + window.getConfig.postMessage = function(callback) { + return window.NativeBridge.getConfig(callback); + }; + if (payloadChanged) { + dispatchConfigReady(normalizedPayload); + postDebug('config.ready', { + provider: normalizedPayload.provider || '', + gameId: normalizedPayload.gameId || '', + roomId: launchConfig.roomId || '', + code: launchConfig.code + ? String(launchConfig.code).slice(0, 6) + '***' + : '', + codeLength: launchConfig.code ? String(launchConfig.code).length : 0, + entryUrl: entry.entryUrl || '' + }); + } + } + + if (!window.__leaderBridgeReady) { + window.__leaderBridgeReady = true; + + function bindCallable(name) { + const callable = function(payload) { + return postMessage(name, payload); + }; + callable.postMessage = function(payload) { + return callable(payload); + }; + window[name] = callable; + return callable; + } + + const pay = bindCallable('${LeaderBridgeActions.pay}'); + const closeGame = bindCallable('${LeaderBridgeActions.closeGame}'); + const loadComplete = bindCallable('${LeaderBridgeActions.loadComplete}'); + + window.NativeBridge = window.NativeBridge || {}; + window.NativeBridge.pay = pay; + window.NativeBridge.closeGame = closeGame; + window.NativeBridge.loadComplete = loadComplete; + + window.__leaderBridgeDebug = postDebug; + postDebug('bridge.ready', { + href: window.location && window.location.href, + hasUpdateCoin: typeof window.updateCoin === 'function' + }); + } + + applyLaunchPayload(launchPayload); + })(); + '''; + } + + static String buildUpdateCoinScript() { + return ''' + (function() { + if (typeof window.updateCoin === 'function') { + window.updateCoin(); + return; + } + if (window.NativeBridge && typeof window.NativeBridge.updateCoin === 'function') { + window.NativeBridge.updateCoin(); + return; + } + if (typeof window.onUpdateCoin === 'function') { + window.onUpdateCoin(); + return; + } + $channelName.postMessage(JSON.stringify({ + action: '${LeaderBridgeActions.debugLog}', + payload: { + tag: 'game.updateCoin.missing', + message: 'No updateCoin handler found on game page.' + } + })); + })(); + '''; + } +} diff --git a/lib/modules/room_game/data/models/room_game_models.dart b/lib/modules/room_game/data/models/room_game_models.dart index 4d75332..a763d88 100644 --- a/lib/modules/room_game/data/models/room_game_models.dart +++ b/lib/modules/room_game/data/models/room_game_models.dart @@ -1,31 +1,80 @@ +class RoomGameProviderModel { + const RoomGameProviderModel({required this.key, required this.displayName}); + + final String key; + final String displayName; + + factory RoomGameProviderModel.fromJson(Map json) { + return RoomGameProviderModel( + key: _asString(json['key']), + displayName: _asString(json['displayName']), + ); + } +} + +class RoomGameProviderListResponseModel { + const RoomGameProviderListResponseModel({required this.items}); + + final List items; + + factory RoomGameProviderListResponseModel.fromJson( + Map json, + ) { + final list = json['items'] as List? ?? const []; + return RoomGameProviderListResponseModel( + items: + list + .whereType>() + .map(RoomGameProviderModel.fromJson) + .toList(), + ); + } +} + class RoomGameShortcutModel { const RoomGameShortcutModel({ required this.gameId, - required this.vendorType, - required this.vendorGameId, + required this.provider, + required this.gameType, + required this.providerGameId, required this.name, required this.cover, required this.launchMode, required this.gameMode, + required this.sort, + required this.launchParams, }); final String gameId; - final String vendorType; - final int vendorGameId; + final String provider; + final String gameType; + final String providerGameId; final String name; final String cover; final String launchMode; final int gameMode; + final int sort; + final Map launchParams; + + String get vendorType => provider; + + String get vendorGameId => providerGameId; factory RoomGameShortcutModel.fromJson(Map json) { return RoomGameShortcutModel( gameId: _asString(json['gameId']), - vendorType: _asString(json['vendorType']), - vendorGameId: _asInt(json['vendorGameId']), + provider: _asString(json['provider'] ?? json['vendorType']), + gameType: + _asString(json['gameType']).isNotEmpty + ? _asString(json['gameType']) + : _asString(json['provider'] ?? json['vendorType']), + providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']), name: _asString(json['name']), cover: _asString(json['cover']), launchMode: _asString(json['launchMode']), gameMode: _asInt(json['gameMode']), + sort: _asInt(json['sort']), + launchParams: _asMap(json['launchParams']), ); } } @@ -33,8 +82,9 @@ class RoomGameShortcutModel { class RoomGameListItemModel { const RoomGameListItemModel({ required this.gameId, - required this.vendorType, - required this.vendorGameId, + required this.provider, + required this.gameType, + required this.providerGameId, required this.name, required this.cover, required this.category, @@ -46,11 +96,13 @@ class RoomGameListItemModel { required this.orientation, required this.packageVersion, required this.status, + required this.launchParams, }); final String gameId; - final String vendorType; - final int vendorGameId; + final String provider; + final String gameType; + final String providerGameId; final String name; final String cover; final String category; @@ -62,14 +114,28 @@ class RoomGameListItemModel { final int orientation; final String packageVersion; final String status; + final Map launchParams; - bool get isBaishun => vendorType.toUpperCase() == 'BAISHUN'; + String get vendorType => provider; + + String get vendorGameId => providerGameId; + + bool get isBaishun => + provider.toUpperCase() == 'BAISHUN' || + gameType.toUpperCase() == 'BAISHUN'; + + bool get isLeader => + provider.toUpperCase() == 'LEADER' || gameType.toUpperCase() == 'LEADER'; factory RoomGameListItemModel.fromJson(Map json) { return RoomGameListItemModel( gameId: _asString(json['gameId']), - vendorType: _asString(json['vendorType']), - vendorGameId: _asInt(json['vendorGameId']), + provider: _asString(json['provider'] ?? json['vendorType']), + gameType: + _asString(json['gameType']).isNotEmpty + ? _asString(json['gameType']) + : _asString(json['provider'] ?? json['vendorType']), + providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']), name: _asString(json['name']), cover: _asString(json['cover']), category: _asString(json['category']), @@ -81,14 +147,16 @@ class RoomGameListItemModel { orientation: _asInt(json['orientation']), packageVersion: _asString(json['packageVersion']), status: _asString(json['status']), + launchParams: _asMap(json['launchParams']), ); } factory RoomGameListItemModel.debugMock() { return const RoomGameListItemModel( gameId: 'bs_mock', - vendorType: 'BAISHUN', - vendorGameId: 999001, + provider: 'BAISHUN', + gameType: 'BAISHUN', + providerGameId: '999001', name: 'BAISHUN Mock', cover: '', category: 'CHAT_ROOM', @@ -100,6 +168,7 @@ class RoomGameListItemModel { orientation: 1, packageVersion: 'debug', status: 'DEBUG', + launchParams: {'gameType': 'BAISHUN'}, ); } } @@ -125,20 +194,27 @@ class BaishunGameConfigModel { const BaishunGameConfigModel({ required this.sceneMode, required this.currencyIcon, + this.rawJson = const {}, }); final int sceneMode; final String currencyIcon; + final Map rawJson; factory BaishunGameConfigModel.fromJson(Map json) { return BaishunGameConfigModel( sceneMode: _asInt(json['sceneMode']), currencyIcon: _asString(json['currencyIcon']), + rawJson: _asMap(json), ); } Map toJson() { - return {'sceneMode': sceneMode, 'currencyIcon': currencyIcon}; + return { + ...rawJson, + 'sceneMode': sceneMode, + 'currencyIcon': currencyIcon, + }; } } @@ -154,6 +230,7 @@ class BaishunBridgeConfigModel { required this.language, required this.gsp, required this.gameConfig, + this.rawJson = const {}, }); final String appName; @@ -166,6 +243,7 @@ class BaishunBridgeConfigModel { final String language; final int gsp; final BaishunGameConfigModel gameConfig; + final Map rawJson; factory BaishunBridgeConfigModel.fromJson(Map json) { return BaishunBridgeConfigModel( @@ -182,11 +260,13 @@ class BaishunBridgeConfigModel { json['gameConfig'] as Map? ?? const {}, ), + rawJson: _asMap(json), ); } Map toJson() { - return { + return { + ...rawJson, 'appName': appName, 'appChannel': appChannel, 'appId': appId, @@ -210,6 +290,7 @@ class BaishunLaunchEntryModel { required this.packageVersion, required this.orientation, required this.safeHeight, + this.rawJson = const {}, }); final String launchMode; @@ -219,6 +300,7 @@ class BaishunLaunchEntryModel { final String packageVersion; final int orientation; final int safeHeight; + final Map rawJson; factory BaishunLaunchEntryModel.fromJson(Map json) { return BaishunLaunchEntryModel( @@ -229,75 +311,121 @@ class BaishunLaunchEntryModel { packageVersion: _asString(json['packageVersion']), orientation: _asInt(json['orientation']), safeHeight: _asInt(json['safeHeight']), + rawJson: _asMap(json), ); } + + Map toJson() { + return { + ...rawJson, + 'launchMode': launchMode, + 'entryUrl': entryUrl, + 'previewUrl': previewUrl, + 'downloadUrl': downloadUrl, + 'packageVersion': packageVersion, + 'orientation': orientation, + 'safeHeight': safeHeight, + }; + } } class BaishunRoomStateModel { const BaishunRoomStateModel({ required this.roomId, required this.state, + required this.provider, required this.gameSessionId, required this.currentGameId, - required this.currentVendorGameId, + required this.currentProviderGameId, required this.currentGameName, required this.currentGameCover, required this.hostUserId, + this.rawJson = const {}, }); final String roomId; final String state; + final String provider; final String gameSessionId; final String currentGameId; - final int currentVendorGameId; + final String currentProviderGameId; final String currentGameName; final String currentGameCover; final int hostUserId; + final Map rawJson; + + String get currentVendorGameId => currentProviderGameId; factory BaishunRoomStateModel.fromJson(Map json) { return BaishunRoomStateModel( roomId: _asString(json['roomId']), state: _asString(json['state']), + provider: _asString(json['provider'] ?? json['vendorType']), gameSessionId: _asString(json['gameSessionId']), - currentGameId: _asString(json['currentGameId']), - currentVendorGameId: _asInt(json['currentVendorGameId']), + currentGameId: _asString(json['currentGameId'] ?? json['currentGameID']), + currentProviderGameId: _asString( + json['currentProviderGameId'] ?? json['currentVendorGameId'], + ), currentGameName: _asString(json['currentGameName']), currentGameCover: _asString(json['currentGameCover']), hostUserId: _asInt(json['hostUserId']), + rawJson: _asMap(json), ); } + + Map toJson() { + return { + ...rawJson, + 'roomId': roomId, + 'state': state, + 'provider': provider, + 'gameSessionId': gameSessionId, + 'currentGameId': currentGameId, + 'currentProviderGameId': currentProviderGameId, + 'currentGameName': currentGameName, + 'currentGameCover': currentGameCover, + 'hostUserId': hostUserId, + }; + } } class BaishunLaunchModel { const BaishunLaunchModel({ required this.gameSessionId, - required this.vendorType, + required this.provider, required this.gameId, - required this.vendorGameId, + required this.providerGameId, required this.entry, - required this.bridgeConfig, + required this.launchConfig, required this.roomState, }); final String gameSessionId; - final String vendorType; + final String provider; final String gameId; - final int vendorGameId; + final String providerGameId; final BaishunLaunchEntryModel entry; - final BaishunBridgeConfigModel bridgeConfig; + final BaishunBridgeConfigModel launchConfig; final BaishunRoomStateModel roomState; + String get vendorType => provider; + + String get vendorGameId => providerGameId; + + BaishunBridgeConfigModel get bridgeConfig => launchConfig; + factory BaishunLaunchModel.fromJson(Map json) { return BaishunLaunchModel( gameSessionId: _asString(json['gameSessionId']), - vendorType: _asString(json['vendorType']), + provider: _asString(json['provider'] ?? json['vendorType']), gameId: _asString(json['gameId']), - vendorGameId: _asInt(json['vendorGameId']), + providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']), entry: BaishunLaunchEntryModel.fromJson( json['entry'] as Map? ?? const {}, ), - bridgeConfig: BaishunBridgeConfigModel.fromJson( - json['bridgeConfig'] as Map? ?? + launchConfig: BaishunBridgeConfigModel.fromJson( + (json['launchConfig'] ?? json['bridgeConfig']) + as Map? ?? const {}, ), roomState: BaishunRoomStateModel.fromJson( @@ -305,6 +433,18 @@ class BaishunLaunchModel { ), ); } + + Map toJson() { + return { + 'gameSessionId': gameSessionId, + 'provider': provider, + 'gameId': gameId, + 'providerGameId': providerGameId, + 'entry': entry.toJson(), + 'launchConfig': launchConfig.toJson(), + 'roomState': roomState.toJson(), + }; + } } String _asString(dynamic value) { @@ -339,3 +479,15 @@ bool _asBool(dynamic value) { } return false; } + +Map _asMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map( + (dynamic key, dynamic item) => MapEntry(key.toString(), item), + ); + } + return const {}; +} diff --git a/lib/modules/room_game/data/room_game_api.dart b/lib/modules/room_game/data/room_game_api.dart index 68aef34..49b7e6b 100644 --- a/lib/modules/room_game/data/room_game_api.dart +++ b/lib/modules/room_game/data/room_game_api.dart @@ -21,11 +21,23 @@ class RoomGameApi { return merged.isEmpty ? null : merged; } + Future fetchProviders() { + return _client.get( + '/app/game/providers', + extra: _buildExtra(), + fromJson: + (dynamic json) => RoomGameProviderListResponseModel.fromJson( + json as Map? ?? const {}, + ), + ); + } + Future> fetchShortcutGames({ + required String provider, required String roomId, }) { return _client.get>( - '/app/game/room/shortcut', + '/app/game/providers/$provider/room/shortcut', queryParams: {'roomId': roomId}, extra: _buildExtra(), fromJson: (dynamic json) { @@ -39,6 +51,7 @@ class RoomGameApi { } Future fetchRoomGames({ + required String provider, required String roomId, String category = '', }) { @@ -47,7 +60,7 @@ class RoomGameApi { query['category'] = category; } return _client.get( - '/app/game/room/list', + '/app/game/providers/$provider/room/list', queryParams: query, extra: _buildExtra(const { BaseNetworkClient.silentErrorToastKey: true, @@ -59,9 +72,12 @@ class RoomGameApi { ); } - Future fetchRoomState({required String roomId}) { + Future fetchRoomState({ + required String provider, + required String roomId, + }) { return _client.get( - '/app/game/baishun/state', + '/app/game/providers/$provider/state', queryParams: {'roomId': roomId}, extra: _buildExtra(), fromJson: @@ -71,19 +87,22 @@ class RoomGameApi { ); } - Future launchBaishunGame({ + Future launchGame({ + required String provider, required String roomId, required String gameId, required int sceneMode, required String clientOrigin, + required Map params, }) { return _client.post( - '/app/game/baishun/launch', + '/app/game/providers/$provider/launch', data: { 'roomId': roomId, 'gameId': gameId, 'sceneMode': sceneMode, 'clientOrigin': clientOrigin, + 'params': params, }, extra: _buildExtra(), fromJson: @@ -93,17 +112,20 @@ class RoomGameApi { ); } - Future closeBaishunGame({ + Future closeGame({ + required String provider, required String roomId, required String gameSessionId, String reason = 'user_exit', + Map params = const {}, }) { return _client.post( - '/app/game/baishun/close', + '/app/game/providers/$provider/close', data: { 'roomId': roomId, 'gameSessionId': gameSessionId, 'reason': reason, + 'params': params, }, extra: _buildExtra(), fromJson: diff --git a/lib/modules/room_game/data/room_game_repository.dart b/lib/modules/room_game/data/room_game_repository.dart index 4c8cb6c..5c47492 100644 --- a/lib/modules/room_game/data/room_game_repository.dart +++ b/lib/modules/room_game/data/room_game_repository.dart @@ -6,47 +6,111 @@ class RoomGameRepository { final RoomGameApi _api; + Future> fetchProviders() async { + final response = await _api.fetchProviders(); + return response.items; + } + Future> fetchShortcutGames({ required String roomId, - }) { - return _api.fetchShortcutGames(roomId: roomId); + }) async { + final providers = await fetchProviders(); + if (providers.isEmpty) { + return const []; + } + + final items = []; + Object? lastError; + for (final provider in providers) { + try { + final result = await _api.fetchShortcutGames( + provider: provider.key, + roomId: roomId, + ); + items.addAll(result); + } catch (error) { + lastError = error; + } + } + + if (items.isEmpty && lastError != null) { + throw lastError; + } + + items.sort((a, b) => a.sort.compareTo(b.sort)); + return items; } Future> fetchRoomGames({ required String roomId, String category = '', }) async { - final response = await _api.fetchRoomGames(roomId: roomId, category: category); - return response.items; + final providers = await fetchProviders(); + if (providers.isEmpty) { + return const []; + } + + final items = []; + Object? lastError; + for (final provider in providers) { + try { + final response = await _api.fetchRoomGames( + provider: provider.key, + roomId: roomId, + category: category, + ); + items.addAll(response.items); + } catch (error) { + lastError = error; + } + } + + if (items.isEmpty && lastError != null) { + throw lastError; + } + + items.sort((a, b) => a.sort.compareTo(b.sort)); + return items; } - Future fetchRoomState({required String roomId}) { - return _api.fetchRoomState(roomId: roomId); + Future fetchRoomState({ + required String provider, + required String roomId, + }) { + return _api.fetchRoomState(provider: provider, roomId: roomId); } - Future launchBaishunGame({ + Future launchGame({ + required String provider, required String roomId, required String gameId, - int sceneMode = 0, + int sceneMode = 1, required String clientOrigin, + Map params = const {}, }) { - return _api.launchBaishunGame( + return _api.launchGame( + provider: provider, roomId: roomId, gameId: gameId, sceneMode: sceneMode, clientOrigin: clientOrigin, + params: params, ); } - Future closeBaishunGame({ + Future closeGame({ + required String provider, required String roomId, required String gameSessionId, String reason = 'user_exit', + Map params = const {}, }) { - return _api.closeBaishunGame( + return _api.closeGame( + provider: provider, roomId: roomId, gameSessionId: gameSessionId, reason: reason, + params: params, ); } } diff --git a/lib/modules/room_game/views/baishun_game_page.dart b/lib/modules/room_game/views/baishun_game_page.dart index 9defbcc..76087f6 100644 --- a/lib/modules/room_game/views/baishun_game_page.dart +++ b/lib/modules/room_game/views/baishun_game_page.dart @@ -33,6 +33,7 @@ class BaishunGamePage extends StatefulWidget { class _BaishunGamePageState extends State { static const String _logPrefix = '[BaishunGame]'; + static const double _designWidth = 750; final RoomGameRepository _repository = RoomGameRepository(); final Set> _webGestureRecognizers = @@ -375,13 +376,15 @@ class _BaishunGamePageState extends State { _log('close_start reason=$reason'); try { if (!widget.launchModel.gameSessionId.startsWith('bs_mock_')) { - final result = await _repository.closeBaishunGame( + final result = await _repository.closeGame( + provider: widget.game.provider, roomId: widget.roomId, gameSessionId: widget.launchModel.gameSessionId, reason: reason, + params: const {}, ); _log( - 'close_success result=${_stringifyForLog({'roomId': result.roomId, 'state': result.state, 'gameSessionId': result.gameSessionId, 'currentGameId': result.currentGameId, 'hostUserId': result.hostUserId})}', + 'close_success result=${_stringifyForLog({'roomId': result.roomId, 'state': result.state, 'provider': result.provider, 'gameSessionId': result.gameSessionId, 'currentGameId': result.currentGameId, 'hostUserId': result.hostUserId})}', ); } } catch (error) { @@ -596,9 +599,38 @@ class _BaishunGamePageState extends State { } } + int _resolveSafeHeight() { + if (widget.launchModel.entry.safeHeight > 0) { + return widget.launchModel.entry.safeHeight; + } + if (widget.game.safeHeight > 0) { + return widget.game.safeHeight; + } + return 0; + } + + double _calculateWebViewHeight(BuildContext context) { + final screenSize = MediaQuery.sizeOf(context); + final safePadding = MediaQuery.paddingOf(context); + final safeHeight = _resolveSafeHeight(); + final fallbackHeight = screenSize.height * 0.5; + if (safeHeight <= 0 || screenSize.width <= 0) { + return fallbackHeight; + } + + final ratio = _designWidth / safeHeight; + final webViewHeight = screenSize.width / ratio; + final maxHeight = screenSize.height - safePadding.top - 12.w; + if (maxHeight <= 0) { + return fallbackHeight; + } + return webViewHeight.clamp(0.0, maxHeight).toDouble(); + } + @override Widget build(BuildContext context) { final topCrop = 28.w; + final webViewHeight = _calculateWebViewHeight(context); return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { @@ -618,7 +650,7 @@ class _BaishunGamePageState extends State { ), child: Container( width: ScreenUtil().screenWidth, - height: ScreenUtil().screenHeight * 0.5, + height: webViewHeight, color: const Color(0xFF081915), child: Stack( children: [ diff --git a/lib/modules/room_game/views/leader_game_page.dart b/lib/modules/room_game/views/leader_game_page.dart new file mode 100644 index 0000000..180f75b --- /dev/null +++ b/lib/modules/room_game/views/leader_game_page.dart @@ -0,0 +1,661 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart'; +import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; +import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/views/baishun_loading_view.dart'; +import 'package:yumi/modules/wallet/wallet_route.dart'; +import 'package:yumi/ui_kit/components/sc_tts.dart'; + +class LeaderGamePage extends StatefulWidget { + const LeaderGamePage({ + super.key, + required this.roomId, + required this.game, + required this.launchModel, + }); + + final String roomId; + final RoomGameListItemModel game; + final BaishunLaunchModel launchModel; + + @override + State createState() => _LeaderGamePageState(); +} + +class _LeaderGamePageState extends State { + static const String _logPrefix = '[LeaderGame]'; + static const double _designWidth = 750; + static const double _hdDesignHeight = 1044; + + final RoomGameRepository _repository = RoomGameRepository(); + final Set> _webGestureRecognizers = + >{ + Factory(() => EagerGestureRecognizer()), + }; + late final WebViewController _controller; + + Timer? _bridgeBootstrapTimer; + Timer? _loadingFallbackTimer; + + bool _isLoading = true; + bool _isClosing = false; + bool _didReceiveBridgeMessage = false; + bool _didFinishPageLoad = false; + int _bridgeInjectCount = 0; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _controller = + WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.black) + ..addJavaScriptChannel( + LeaderJsBridge.channelName, + onMessageReceived: _handleBridgeMessage, + ) + ..addJavaScriptChannel( + LeaderBridgeActions.getConfig, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + LeaderBridgeActions.getConfig, + message, + ), + ) + ..addJavaScriptChannel( + LeaderBridgeActions.pay, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + LeaderBridgeActions.pay, + message, + ), + ) + ..addJavaScriptChannel( + LeaderBridgeActions.closeGame, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + LeaderBridgeActions.closeGame, + message, + ), + ) + ..addJavaScriptChannel( + LeaderBridgeActions.loadComplete, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + LeaderBridgeActions.loadComplete, + message, + ), + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + _log('page_started url=${_clip(url, 240)}'); + _prepareForPageLoad(); + }, + onPageFinished: (String url) async { + _didFinishPageLoad = true; + _log('page_finished url=${_clip(url, 240)}'); + await _injectBridge(reason: 'page_finished'); + }, + onWebResourceError: (WebResourceError error) { + _log( + 'web_resource_error code=${error.errorCode} ' + 'type=${error.errorType} desc=${_clip(error.description, 300)}', + ); + _stopBridgeBootstrap(reason: 'web_resource_error'); + if (!mounted) { + return; + } + setState(() { + _errorMessage = error.description; + _isLoading = false; + }); + }, + ), + ); + _log('init launch=${_stringifyForLog(_buildLaunchSummary())}'); + unawaited(_loadGameEntry()); + } + + @override + void dispose() { + _stopBridgeBootstrap(reason: 'dispose'); + super.dispose(); + } + + void _prepareForPageLoad() { + _didReceiveBridgeMessage = false; + _didFinishPageLoad = false; + _bridgeInjectCount = 0; + _stopBridgeBootstrap(reason: 'prepare_page_load'); + _bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), ( + Timer timer, + ) { + if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { + timer.cancel(); + return; + } + unawaited(_injectBridge(reason: 'bootstrap')); + if (_didFinishPageLoad && timer.tick >= 12) { + timer.cancel(); + } + }); + _loadingFallbackTimer = Timer(const Duration(seconds: 6), () { + if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { + return; + } + _log('loading_fallback_fire'); + setState(() { + _isLoading = false; + }); + }); + if (!mounted) { + return; + } + setState(() { + _isLoading = true; + _errorMessage = null; + }); + } + + void _stopBridgeBootstrap({String reason = 'manual'}) { + if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) { + _log('stop_bootstrap reason=$reason'); + } + _bridgeBootstrapTimer?.cancel(); + _bridgeBootstrapTimer = null; + _loadingFallbackTimer?.cancel(); + _loadingFallbackTimer = null; + } + + Future _loadGameEntry() async { + try { + _prepareForPageLoad(); + final entryUrl = widget.launchModel.entry.entryUrl.trim(); + _log( + 'load_entry launchMode=${widget.launchModel.entry.launchMode} ' + 'entryUrl=${_clip(entryUrl, 240)}', + ); + if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) { + await _controller.loadHtmlString(_buildMockHtml()); + return; + } + final uri = Uri.tryParse(entryUrl); + if (uri == null) { + throw Exception('Invalid game entry url: $entryUrl'); + } + await _controller.loadRequest(uri); + } catch (error) { + _log('load_entry_error error=${_clip(error.toString(), 400)}'); + if (!mounted) { + return; + } + setState(() { + _errorMessage = error.toString(); + _isLoading = false; + }); + } + } + + Future _injectBridge({String reason = 'manual'}) async { + _bridgeInjectCount += 1; + if (reason != 'bootstrap' || + _bridgeInjectCount <= 3 || + _bridgeInjectCount % 5 == 0) { + _log('inject_bridge reason=$reason count=$_bridgeInjectCount'); + } + try { + await _controller.runJavaScript( + LeaderJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()), + ); + } catch (error) { + _log( + 'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}', + ); + } + } + + Future _handleBridgeMessage(JavaScriptMessage message) async { + _log('channel_message raw=${_clip(message.message, 600)}'); + final bridgeMessage = LeaderBridgeMessage.parse(message.message); + await _dispatchBridgeMessage(bridgeMessage); + } + + Future _handleNamedChannelMessage( + String action, + JavaScriptMessage message, + ) async { + final bridgeMessage = LeaderBridgeMessage.fromAction( + action, + message.message, + ); + _log('named_channel action=$action raw=${_clip(message.message, 600)}'); + await _dispatchBridgeMessage(bridgeMessage); + } + + Future _dispatchBridgeMessage(LeaderBridgeMessage bridgeMessage) async { + if (bridgeMessage.action == LeaderBridgeActions.debugLog) { + final tag = bridgeMessage.payload['tag']?.toString().trim(); + final message = bridgeMessage.payload['message']?.toString() ?? ''; + _log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}'); + return; + } + + _didReceiveBridgeMessage = true; + _stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}'); + _log( + 'bridge_action action=${bridgeMessage.action} ' + 'payload=${_clip(bridgeMessage.payload.toString(), 800)}', + ); + + switch (bridgeMessage.action) { + case LeaderBridgeActions.getConfig: + await _injectBridge(reason: 'get_config'); + break; + case LeaderBridgeActions.pay: + await SCNavigatorUtils.push(context, WalletRoute.recharge); + await _notifyUpdateCoin(); + break; + case LeaderBridgeActions.closeGame: + await _closeAndExit(reason: 'h5_closeGame'); + break; + case LeaderBridgeActions.loadComplete: + if (!mounted) { + return; + } + setState(() { + _isLoading = false; + _errorMessage = null; + }); + break; + default: + _log('bridge_action_unhandled action=${bridgeMessage.action}'); + break; + } + } + + Future _notifyUpdateCoin() async { + _log('notify_update_coin'); + try { + await _controller.runJavaScript(LeaderJsBridge.buildUpdateCoinScript()); + } catch (error) { + _log('notify_update_coin_error error=${_clip(error.toString(), 300)}'); + } + } + + Future _reload() async { + if (!mounted) { + return; + } + setState(() { + _errorMessage = null; + _isLoading = true; + }); + await _loadGameEntry(); + } + + Future _closeAndExit({String reason = 'user_exit'}) async { + if (_isClosing) { + return; + } + _isClosing = true; + try { + final sessionId = widget.launchModel.gameSessionId.trim(); + if (sessionId.isNotEmpty && !sessionId.startsWith('mock')) { + await _repository.closeGame( + provider: widget.game.provider, + roomId: widget.roomId, + gameSessionId: sessionId, + reason: reason, + params: const {}, + ); + } + } catch (error) { + _log('close_error error=${_clip(error.toString(), 300)}'); + } + if (mounted) { + Navigator.of(context).pop(); + } + } + + String _resolveScreenMode() { + final launchParams = widget.game.launchParams; + final candidates = [ + launchParams['screenMode']?.toString() ?? '', + launchParams['screen_mode']?.toString() ?? '', + launchParams['displayMode']?.toString() ?? '', + launchParams['gameScreenMode']?.toString() ?? '', + ]; + for (final candidate in candidates) { + final trimmed = candidate.trim(); + if (trimmed.isNotEmpty) { + return trimmed.toLowerCase(); + } + } + if (widget.game.fullScreen) { + return 'full'; + } + return 'half'; + } + + int _resolveSafeHeight() { + if (widget.launchModel.entry.safeHeight > 0) { + return widget.launchModel.entry.safeHeight; + } + if (widget.game.safeHeight > 0) { + return widget.game.safeHeight; + } + return 0; + } + + double _calculateBodyHeight(BuildContext context) { + final screenSize = MediaQuery.sizeOf(context); + final safePadding = MediaQuery.paddingOf(context); + final screenMode = _resolveScreenMode(); + final safeHeight = _resolveSafeHeight(); + final fallback = screenSize.width; + final maxHeight = screenSize.height - safePadding.top - 24.w; + if (maxHeight <= 0) { + return fallback; + } + + if (screenMode == 'full') { + return maxHeight; + } + + if (safeHeight > 0 && screenSize.width > 0) { + final ratio = _designWidth / safeHeight; + final bodyHeight = screenSize.width / ratio; + return bodyHeight.clamp(0.0, maxHeight).toDouble(); + } + + if (screenMode == 'hd') { + final bodyHeight = screenSize.width * _hdDesignHeight / _designWidth; + return bodyHeight.clamp(0.0, maxHeight).toDouble(); + } + + return fallback.clamp(0.0, maxHeight).toDouble(); + } + + void _log(String message) { + debugPrint('$_logPrefix $message'); + } + + String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + if (trimmed.length <= keepStart + keepEnd) { + return trimmed; + } + return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}'; + } + + Object? _sanitizeForLog(Object? value, {String key = ''}) { + final lowerKey = key.toLowerCase(); + final shouldMask = + lowerKey.contains('code') || + lowerKey.contains('token') || + lowerKey.contains('authorization'); + if (value is Map) { + final result = {}; + for (final MapEntry entry in value.entries) { + result[entry.key.toString()] = _sanitizeForLog( + entry.value, + key: entry.key.toString(), + ); + } + return result; + } + if (value is List) { + return value.map((Object? item) => _sanitizeForLog(item)).toList(); + } + if (value is String) { + final normalized = shouldMask ? _maskValue(value) : value; + return _clip(normalized, 600); + } + return value; + } + + String _clip(String value, [int limit = 600]) { + final trimmed = value.trim(); + if (trimmed.length <= limit) { + return trimmed; + } + return '${trimmed.substring(0, limit)}...'; + } + + String _stringifyForLog(Object? value) { + if (value == null) { + return ''; + } + try { + return _clip(jsonEncode(value), 800); + } catch (_) { + return _clip(value.toString(), 800); + } + } + + Map _buildLaunchPayload() { + return { + 'provider': widget.launchModel.provider, + 'gameId': widget.launchModel.gameId, + 'providerGameId': widget.launchModel.providerGameId, + 'gameSessionId': widget.launchModel.gameSessionId, + 'entry': widget.launchModel.entry.toJson(), + 'launchConfig': widget.launchModel.launchConfig.toJson(), + 'roomState': widget.launchModel.roomState.toJson(), + 'game': { + 'gameId': widget.game.gameId, + 'provider': widget.game.provider, + 'gameType': widget.game.gameType, + 'name': widget.game.name, + 'launchMode': widget.game.launchMode, + }, + }; + } + + Map _buildLaunchSummary() { + final payload = _buildLaunchPayload(); + return { + 'roomId': widget.roomId, + 'provider': widget.game.provider, + 'gameId': widget.game.gameId, + 'providerGameId': widget.launchModel.providerGameId, + 'gameSessionId': widget.launchModel.gameSessionId, + 'entry': _sanitizeForLog(payload['entry']), + 'launchConfig': _sanitizeForLog(payload['launchConfig']), + 'roomState': _sanitizeForLog(payload['roomState']), + }; + } + + String _buildMockHtml() { + return ''' + + + + + + + + +
+

Leader Mock

+

Use these buttons to verify bridge wiring.

+ + + + + +
+
+ + + + '''; + } + + @override + Widget build(BuildContext context) { + final bodyHeight = _calculateBodyHeight(context); + final headerHeight = 44.w; + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (didPop) { + return; + } + await _closeAndExit(); + }, + child: Material( + color: Colors.transparent, + child: Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.w), + topRight: Radius.circular(24.w), + ), + child: Container( + width: ScreenUtil().screenWidth, + height: bodyHeight + headerHeight, + color: const Color(0xFF081915), + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: headerHeight, + child: _buildHeader(), + ), + Positioned( + left: 0, + right: 0, + top: headerHeight, + bottom: 0, + child: WebViewWidget( + controller: _controller, + gestureRecognizers: _webGestureRecognizers, + ), + ), + if (_errorMessage != null) _buildErrorState(), + if (_isLoading && _errorMessage == null) + const IgnorePointer( + ignoring: true, + child: BaishunLoadingView( + message: 'Waiting for Leader game...', + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + color: const Color(0xFF102D28), + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + Expanded( + child: Text( + widget.game.name.isEmpty ? 'Leader Game' : widget.game.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () { + unawaited(_closeAndExit()); + }, + icon: Icon(Icons.close, color: Colors.white, size: 20.w), + splashRadius: 18.w, + ), + ], + ), + ); + } + + Widget _buildErrorState() { + return Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.88), + padding: EdgeInsets.symmetric(horizontal: 28.w), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: Colors.white70, size: 34.w), + SizedBox(height: 12.w), + Text( + 'Leader game failed to load', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.w), + Text( + _errorMessage ?? '', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 12.sp), + ), + SizedBox(height: 16.w), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton(onPressed: _reload, child: const Text('Retry')), + SizedBox(width: 10.w), + TextButton( + onPressed: () { + SCTts.show('Please verify the Leader entry url'); + }, + child: const Text('Tips'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/room_game/views/room_game_list_sheet.dart b/lib/modules/room_game/views/room_game_list_sheet.dart index 621186c..9ed7ff1 100644 --- a/lib/modules/room_game/views/room_game_list_sheet.dart +++ b/lib/modules/room_game/views/room_game_list_sheet.dart @@ -1,11 +1,11 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; +import 'dart:convert'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; import 'package:yumi/modules/room_game/views/baishun_game_page.dart'; +import 'package:yumi/modules/room_game/views/leader_game_page.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; @@ -22,7 +22,7 @@ class RoomGameListSheet extends StatefulWidget { } class _RoomGameListSheetState extends State { - static const String _logPrefix = '[BaishunLaunch]'; + static const String _logPrefix = '[RoomGameLaunch]'; static const int _itemsPerRow = 4; static const String _sheetFrameAsset = 'sc_images/room/sc_room_game_sheet_frame.png'; @@ -69,8 +69,8 @@ class _RoomGameListSheetState extends State { SCTts.show('roomId is empty'); return; } - if (!game.isBaishun) { - SCTts.show('Only BAISHUN games are wired for now'); + if (!game.isBaishun && !game.isLeader) { + SCTts.show('This game provider is not wired yet'); return; } @@ -81,28 +81,19 @@ class _RoomGameListSheetState extends State { try { _log( 'launch_request roomId=$_roomId gameId=${game.gameId} ' - 'vendorGameId=${game.vendorGameId} gameMode=${game.gameMode} ' - 'origin=${Platform.isAndroid ? 'ANDROID' : 'IOS'}', + 'provider=${game.provider} gameType=${game.gameType} ' + 'providerGameId=${game.providerGameId} gameMode=${game.gameMode} ' + 'origin=COMMON params=${_stringifyForLog(game.launchParams)}', ); - final launchModel = await _repository.launchBaishunGame( + final launchModel = await _repository.launchGame( + provider: game.provider, roomId: _roomId, gameId: game.gameId, - clientOrigin: Platform.isAndroid ? 'ANDROID' : 'IOS', + clientOrigin: 'COMMON', + params: game.launchParams, ); _log( - 'launch_success payload=${_stringifyForLog({ - 'gameSessionId': launchModel.gameSessionId, - 'vendorType': launchModel.vendorType, - 'gameId': launchModel.gameId, - 'vendorGameId': launchModel.vendorGameId, - 'entryUrl': _clip(launchModel.entry.entryUrl, 240), - 'launchMode': launchModel.entry.launchMode, - 'packageVersion': launchModel.entry.packageVersion, - 'orientation': launchModel.entry.orientation, - 'safeHeight': launchModel.entry.safeHeight, - 'roomState': launchModel.roomState.state, - 'bridgeConfig': {'userId': launchModel.bridgeConfig.userId, 'roomId': launchModel.bridgeConfig.roomId, 'gameMode': launchModel.bridgeConfig.gameMode, 'language': launchModel.bridgeConfig.language, 'gsp': launchModel.bridgeConfig.gsp, 'code': _maskValue(launchModel.bridgeConfig.code), 'codeLength': launchModel.bridgeConfig.code.length}, - })}', + 'launch_success payload=${_stringifyForLog({'gameSessionId': launchModel.gameSessionId, 'provider': launchModel.provider, 'gameId': launchModel.gameId, 'providerGameId': launchModel.providerGameId, 'entryUrl': _clip(launchModel.entry.entryUrl, 240), 'launchMode': launchModel.entry.launchMode, 'packageVersion': launchModel.entry.packageVersion, 'orientation': launchModel.entry.orientation, 'safeHeight': launchModel.entry.safeHeight, 'roomState': launchModel.roomState.state, 'launchConfig': _sanitizeForLog(launchModel.launchConfig.toJson())})}', ); if (!mounted) { return; @@ -113,9 +104,14 @@ class _RoomGameListSheetState extends State { if (!widget.roomContext.mounted) { return; } + final gamePage = _buildGamePage(game, launchModel); + if (gamePage == null) { + SCTts.show('This game provider is not wired yet'); + return; + } showBottomInBottomDialog( widget.roomContext, - BaishunGamePage(roomId: _roomId, game: game, launchModel: launchModel), + gamePage, barrierColor: Colors.black54, barrierDismissible: true, ); @@ -158,7 +154,58 @@ class _RoomGameListSheetState extends State { if (value == null) { return ''; } - return _clip(value.toString(), 800); + try { + return _clip(jsonEncode(value), 800); + } catch (_) { + return _clip(value.toString(), 800); + } + } + + Object? _sanitizeForLog(Object? value, {String key = ''}) { + final lowerKey = key.toLowerCase(); + final shouldMask = + lowerKey.contains('code') || + lowerKey.contains('token') || + lowerKey.contains('authorization'); + if (value is Map) { + final result = {}; + for (final MapEntry entry in value.entries) { + result[entry.key.toString()] = _sanitizeForLog( + entry.value, + key: entry.key.toString(), + ); + } + return result; + } + if (value is List) { + return value.map((Object? item) => _sanitizeForLog(item)).toList(); + } + if (value is String) { + final normalized = shouldMask ? _maskValue(value) : value; + return _clip(normalized, 600); + } + return value; + } + + Widget? _buildGamePage( + RoomGameListItemModel game, + BaishunLaunchModel launchModel, + ) { + if (game.isBaishun) { + return BaishunGamePage( + roomId: _roomId, + game: game, + launchModel: launchModel, + ); + } + if (game.isLeader) { + return LeaderGamePage( + roomId: _roomId, + game: game, + launchModel: launchModel, + ); + } + return null; } @override diff --git a/lib/modules/user/me_page2.dart b/lib/modules/user/me_page2.dart index aeafacc..c4b019c 100644 --- a/lib/modules/user/me_page2.dart +++ b/lib/modules/user/me_page2.dart @@ -158,9 +158,12 @@ class _MePage2State extends State { idText: profile?.getID() ?? '', showAnimated: profile?.hasSpecialId() ?? false, assetPath: SCSpecialIdAssets.userIdLarge, - animationWidth: 150.w, - animationHeight: 34.w, - textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w), + animationWidth: 80.w, + animationHeight: 32.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + profile?.shouldShowColoredSpecialIdText() ?? false, animationTextStyle: TextStyle( color: Colors.white, fontSize: 16.sp, @@ -171,6 +174,7 @@ class _MePage2State extends State { fontSize: 16.sp, fontWeight: FontWeight.w600, ), + animationFit: BoxFit.contain, ), ], ), diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 882163a..c329dfe 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -397,15 +397,20 @@ class _PersonDetailPageState extends State ), SizedBox(height: 5.w), Row( + mainAxisAlignment: MainAxisAlignment.start, children: [ SizedBox(width: 25.w), SCSpecialIdBadge( idText: ref.userProfile?.getID() ?? "", showAnimated: ref.userProfile?.hasSpecialId() ?? false, assetPath: SCSpecialIdAssets.userIdLarge, - animationWidth: 150.w, - animationHeight: 34.w, - textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w), + animationWidth: 80.w, + animationHeight: 32.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + ref.userProfile?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( color: Colors.white, fontSize: 16.sp, @@ -416,6 +421,7 @@ class _PersonDetailPageState extends State fontSize: 16.sp, fontWeight: FontWeight.w400, ), + animationFit: BoxFit.contain, ), ], ), diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index e7309dc..6106508 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:dio/dio.dart'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:fluro/fluro.dart'; @@ -60,6 +61,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { Timer? _onlineUsersPollingTimer; bool _isRefreshingMicList = false; bool _isRefreshingOnlineUsers = false; + bool _disableMicListRefreshForCurrentSession = false; + bool _disableOnlineUsersRefreshForCurrentSession = false; bool _pendingMicListRefresh = false; Timer? _deferredMicListRefreshTimer; int _lastMicListRefreshStartedAtMs = 0; @@ -166,6 +169,28 @@ class RealTimeCommunicationManager extends ChangeNotifier { _isRefreshingOnlineUsers = false; } + bool _isMissingPathError(Object error) { + final normalizedMessage = _errorMessageFrom(error).toLowerCase(); + return normalizedMessage.contains('path was not found') || + normalizedMessage.contains('the path was not found'); + } + + String _errorMessageFrom(Object error) { + if (error is DioException) { + final responseData = error.response?.data; + if (responseData is Map) { + final dynamic errorMsg = responseData["errorMsg"]; + if (errorMsg != null) { + return errorMsg.toString(); + } + } + if ((error.message ?? "").trim().isNotEmpty) { + return error.message!; + } + } + return error.toString(); + } + bool _sameOnlineUsers( List previous, List next, @@ -687,7 +712,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Duration minInterval = Duration.zero, }) { final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; - if (roomId.isEmpty) { + if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) { return; } @@ -1027,17 +1052,15 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool needOpenRedenvelope = false, String? redPackId, }) async { + _disableMicListRefreshForCurrentSession = false; + _disableOnlineUsersRefreshForCurrentSession = false; if ((currenRoom?.roomProfile?.roomProfile?.event == SCRoomInfoEventType.WAITING_CONFIRMED.name || currenRoom?.roomProfile?.roomProfile?.event == SCRoomInfoEventType.ID_CHANGE.name) && currenRoom?.entrants?.roles == SCRoomRolesType.HOMEOWNER.name) { ///需要去修改房间信息,创建群聊 - SCNavigatorUtils.push( - context!, - "${VoiceRoomRoute.roomEdit}?need=true", - replace: false, - ); + VoiceRoomRoute.openRoomEdit(context!, needRestCurrentRoomInfo: true); return Future; } SCRoomUtils.roomSCGlobalConfig( @@ -1143,12 +1166,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///获取在线用户 Future fetchOnlineUsersList({bool notifyIfUnchanged = true}) async { final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; - if (roomId.isEmpty || _isRefreshingOnlineUsers) { + if (roomId.isEmpty || + _isRefreshingOnlineUsers || + _disableOnlineUsersRefreshForCurrentSession) { return; } _isRefreshingOnlineUsers = true; try { - final fetchedUsers = await SCChatRoomRepository().roomOnlineUsers(roomId); + final fetchedUsers = await SCChatRoomRepository().roomOnlineUsers( + roomId, + silentErrorToast: true, + ); if (roomId != currenRoom?.roomProfile?.roomProfile?.id) { return; } @@ -1158,6 +1186,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (changed || notifyIfUnchanged) { notifyListeners(); } + } catch (error) { + if (_isMissingPathError(error)) { + _disableOnlineUsersRefreshForCurrentSession = true; + debugPrint( + '[Room][OnlineUsers] suspend auto refresh for current session: ${_errorMessageFrom(error)}', + ); + } else { + debugPrint( + '[Room][OnlineUsers] refresh failed: ${_errorMessageFrom(error)}', + ); + } } finally { _isRefreshingOnlineUsers = false; } @@ -1165,7 +1204,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future retrieveMicrophoneList({bool notifyIfUnchanged = true}) async { final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; - if (roomId.isEmpty) { + if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) { return; } if (_isRefreshingMicList) { @@ -1175,12 +1214,31 @@ class RealTimeCommunicationManager extends ChangeNotifier { _isRefreshingMicList = true; _lastMicListRefreshStartedAtMs = DateTime.now().millisecondsSinceEpoch; try { - final roomWheatList = await SCChatRoomRepository().micList(roomId); + final roomWheatList = await SCChatRoomRepository().micList( + roomId, + silentErrorToast: true, + ); if (roomId != currenRoom?.roomProfile?.roomProfile?.id) { return; } final nextMap = _buildMicMap(roomWheatList); _applyMicSnapshot(nextMap, notifyIfUnchanged: notifyIfUnchanged); + } catch (error) { + if (_isMissingPathError(error)) { + _disableMicListRefreshForCurrentSession = true; + _deferredMicListRefreshTimer?.cancel(); + _deferredMicListRefreshTimer = null; + debugPrint( + '[Room][MicList] suspend auto refresh for current session: ${_errorMessageFrom(error)}', + ); + if (notifyIfUnchanged) { + notifyListeners(); + } + } else { + debugPrint( + '[Room][MicList] refresh failed: ${_errorMessageFrom(error)}', + ); + } } finally { _isRefreshingMicList = false; if (_pendingMicListRefresh) { @@ -1284,6 +1342,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { engine = null; _resetHeartbeatTracking(); _resetLocalAudioRuntimeTracking(); + _disableMicListRefreshForCurrentSession = false; + _disableOnlineUsersRefreshForCurrentSession = false; roomRocketStatus = null; rtmProvider ?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] = diff --git a/lib/shared/business_logic/models/res/login_res.dart b/lib/shared/business_logic/models/res/login_res.dart index 5bd258e..500cdaa 100644 --- a/lib/shared/business_logic/models/res/login_res.dart +++ b/lib/shared/business_logic/models/res/login_res.dart @@ -585,6 +585,19 @@ class SocialChatUserProfile { return false; } + /// ID 数字渐变字的 VIP 条件预留。 + /// 当前先复用 useProps 里的 NOBLE_VIP,道具接口稳定后可切到独立字段。 + bool hasVipPrivilegeForColoredId() { + return _useProps?.any( + (value) => value.propsResources?.type == SCPropsType.NOBLE_VIP.name, + ) ?? + false; + } + + bool shouldShowColoredSpecialIdText() { + return hasVipPrivilegeForColoredId(); + } + void setHeartbeatVal(num? heartbeatVal) { _heartbeatVal = (_heartbeatVal ?? 0) + (heartbeatVal ?? 0); } diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 85f7cb4..ff630bd 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -32,7 +32,7 @@ abstract class SocialChatRoomRepository { Future> discovery({bool? allRegion}); ///房间麦位列表 - Future> micList(String roomId); + Future> micList(String roomId, {bool silentErrorToast = false}); ///获取房间信息 Future specific(String roomId); @@ -69,7 +69,10 @@ abstract class SocialChatRoomRepository { Future micKill(String roomId, num mickIndex); ///房间在线用户 - Future> roomOnlineUsers(String roomId); + Future> roomOnlineUsers( + String roomId, { + bool silentErrorToast = false, + }); ///房间用户资料卡 Future roomUserCard(String roomId, String userId); diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index f64bc4f..63142f0 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -107,12 +107,19 @@ class SCChatRoomRepository implements SocialChatRoomRepository { ///live/mic/list @override - Future> micList(String roomId) async { + Future> micList( + String roomId, { + bool silentErrorToast = false, + }) async { Map queryParams = {}; queryParams["roomId"] = roomId; final result = await http.get( "b1d6a1284f5ddeaa1cbb1430f2991e8a", queryParams: queryParams, + extra: + silentErrorToast + ? const {BaseNetworkClient.silentErrorToastKey: true} + : null, fromJson: (json) => (json as List).map((e) => MicRes.fromJson(e)).toList(), ); @@ -244,10 +251,17 @@ class SCChatRoomRepository implements SocialChatRoomRepository { ///live/user/list @override - Future> roomOnlineUsers(String roomId) async { + Future> roomOnlineUsers( + String roomId, { + bool silentErrorToast = false, + }) async { final result = await http.get( "a01ade8fbb604c0904557a9483fcc4d9", queryParams: {"roomId": roomId}, + extra: + silentErrorToast + ? const {BaseNetworkClient.silentErrorToastKey: true} + : null, fromJson: (json) => (json as List) diff --git a/lib/ui_kit/widgets/id/sc_special_id_badge.dart b/lib/ui_kit/widgets/id/sc_special_id_badge.dart index 21c0e01..39c7d57 100644 --- a/lib/ui_kit/widgets/id/sc_special_id_badge.dart +++ b/lib/ui_kit/widgets/id/sc_special_id_badge.dart @@ -8,6 +8,18 @@ class SCSpecialIdAssets { static const String userIdLarge = 'sc_images/general/user_id_4.svga'; } +const LinearGradient _scSpecialIdVipTextGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0xFFFFD24D), + Color(0xFFFF7A4D), + Color(0xFFFF4FB3), + Color(0xFF8E63FF), + Color(0xFF38D6FF), + ], +); + class SCSpecialIdBadge extends StatelessWidget { const SCSpecialIdBadge({ super.key, @@ -17,10 +29,15 @@ class SCSpecialIdBadge extends StatelessWidget { this.animationWidth = 120, this.animationHeight = 28, this.textPadding = EdgeInsets.zero, + this.showTextWhenAnimated = false, + this.showTextBesideAnimated = false, + this.animatedTextSpacing = 0, + this.animatedTextPrefix = '', this.animationTextStyle, this.normalTextStyle, this.normalPrefix = 'ID:', this.showCopyIcon = false, + this.showCopyIconWhenAnimated = false, this.copyIconAssetPath = 'sc_images/room/sc_icon_user_card_copy_id.png', this.copyIconSize = 12, this.copyIconSpacing = 5, @@ -28,6 +45,9 @@ class SCSpecialIdBadge extends StatelessWidget { this.loop = true, this.active = true, this.animationFit = BoxFit.fill, + this.inlineAnimationWidth, + this.showAnimatedGradientText = false, + this.animatedTextGradient, }); final String idText; @@ -36,10 +56,15 @@ class SCSpecialIdBadge extends StatelessWidget { final double animationWidth; final double animationHeight; final EdgeInsetsGeometry textPadding; + final bool showTextWhenAnimated; + final bool showTextBesideAnimated; + final double animatedTextSpacing; + final String animatedTextPrefix; final TextStyle? animationTextStyle; final TextStyle? normalTextStyle; final String normalPrefix; final bool showCopyIcon; + final bool showCopyIconWhenAnimated; final String copyIconAssetPath; final double copyIconSize; final double copyIconSpacing; @@ -47,6 +72,9 @@ class SCSpecialIdBadge extends StatelessWidget { final bool loop; final bool active; final BoxFit animationFit; + final double? inlineAnimationWidth; + final bool showAnimatedGradientText; + final Gradient? animatedTextGradient; @override Widget build(BuildContext context) { @@ -58,11 +86,15 @@ class SCSpecialIdBadge extends StatelessWidget { final children = [ shouldAnimate - ? _buildAnimatedBadge(normalizedId) + ? (showTextWhenAnimated + ? _buildAnimatedBadge(normalizedId) + : (showTextBesideAnimated + ? _buildAnimatedInlineBadge(normalizedId) + : _buildAnimatedAsset())) : _buildPlainBadge(normalizedId), ]; - if (showCopyIcon) { + if (showCopyIcon && (!shouldAnimate || showCopyIconWhenAnimated)) { children.add(SizedBox(width: copyIconSpacing)); children.add( Image.asset( @@ -81,6 +113,85 @@ class SCSpecialIdBadge extends StatelessWidget { ); } + Widget _buildAnimatedAsset() { + return SizedBox( + width: animationWidth, + height: animationHeight, + child: SCSvgaAssetWidget( + assetPath: assetPath!, + width: animationWidth, + height: animationHeight, + active: active, + loop: loop, + fit: animationFit, + fallback: const SizedBox.shrink(), + ), + ); + } + + Widget _buildAnimatedInlineBadge(String normalizedId) { + final compactAnimationWidth = inlineAnimationWidth ?? animationHeight; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: compactAnimationWidth, + height: animationHeight, + child: SCSvgaAssetWidget( + assetPath: assetPath!, + width: compactAnimationWidth, + height: animationHeight, + active: active, + loop: loop, + fit: animationFit, + fallback: const SizedBox.shrink(), + ), + ), + SizedBox(width: animatedTextSpacing), + _buildGradientAwareText('$animatedTextPrefix$normalizedId'), + ], + ); + } + + Widget _buildGradientAwareText( + String text, { + TextStyle? styleOverride, + TextOverflow overflow = TextOverflow.ellipsis, + }) { + final style = + styleOverride ?? + animationTextStyle ?? + const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ); + + if (!showAnimatedGradientText) { + return Text(text, maxLines: 1, overflow: overflow, style: style); + } + + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) { + final shaderBounds = Rect.fromLTWH( + 0, + 0, + bounds.width <= 0 ? 1 : bounds.width, + bounds.height <= 0 ? 1 : bounds.height, + ); + return (animatedTextGradient ?? _scSpecialIdVipTextGradient) + .createShader(shaderBounds); + }, + child: Text( + text, + maxLines: 1, + overflow: overflow, + style: style.copyWith(color: Colors.white), + ), + ); + } + Widget _buildAnimatedBadge(String normalizedId) { return SizedBox( width: animationWidth, @@ -107,17 +218,9 @@ class SCSpecialIdBadge extends StatelessWidget { child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: Text( + child: _buildGradientAwareText( normalizedId, - maxLines: 1, overflow: TextOverflow.visible, - style: - animationTextStyle ?? - const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), ), ), ), @@ -129,11 +232,9 @@ class SCSpecialIdBadge extends StatelessWidget { } Widget _buildPlainBadge(String normalizedId) { - return Text( + return _buildGradientAwareText( '$normalPrefix$normalizedId', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: + styleOverride: normalTextStyle ?? const TextStyle( color: Colors.white, diff --git a/lib/ui_kit/widgets/room/room_head_widget.dart b/lib/ui_kit/widgets/room/room_head_widget.dart index 07f964d..3539d48 100644 --- a/lib/ui_kit/widgets/room/room_head_widget.dart +++ b/lib/ui_kit/widgets/room/room_head_widget.dart @@ -5,13 +5,12 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/modules/room/detail/room_detail_page.dart'; import 'package:yumi/modules/room/voice_room_route.dart'; +import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart'; import 'package:yumi/ui_kit/widgets/room/exit_min_room_page.dart'; import 'package:yumi/ui_kit/widgets/room/sc_edit_room_announcement_page.dart'; @@ -34,6 +33,12 @@ class _RoomHeadWidgetState extends State { roomCover: room?.roomProfile?.roomProfile?.roomCover, roomName: room?.roomProfile?.roomProfile?.roomName ?? "", roomDisplayId: room?.roomProfile?.userProfile?.getID() ?? "", + roomShowAnimatedId: + room?.roomProfile?.userProfile?.hasSpecialId() ?? false, + roomShowGradientId: + room?.roomProfile?.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, roomOwnerUserId: roomOwnerUserId, isRoomOwner: roomOwnerUserId == currentUserId, ); @@ -142,11 +147,28 @@ class _RoomHeadWidgetState extends State { ), ), - text( - "ID:${roomSnapshot.roomDisplayId}", - fontSize: 13.sp, - fontWeight: FontWeight.w600, - textColor: Colors.white70, + SCSpecialIdBadge( + idText: roomSnapshot.roomDisplayId, + showAnimated: roomSnapshot.roomShowAnimatedId, + assetPath: SCSpecialIdAssets.roomOwnerId, + animationWidth: 48.w, + animationHeight: 18.w, + inlineAnimationWidth: 18.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + roomSnapshot.roomShowGradientId, + animationTextStyle: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + normalTextStyle: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + animationFit: BoxFit.contain, ), ], ), @@ -190,10 +212,9 @@ class _RoomHeadWidgetState extends State { ), onTap: () { if (context.read().isFz()) { - SCNavigatorUtils.push( + VoiceRoomRoute.openRoomEdit( context, - "${VoiceRoomRoute.roomEdit}?need=false", - replace: false, + needRestCurrentRoomInfo: false, ); } else { SmartDialog.show( @@ -242,6 +263,8 @@ class _RoomHeadSnapshot { required this.roomCover, required this.roomName, required this.roomDisplayId, + required this.roomShowAnimatedId, + required this.roomShowGradientId, required this.roomOwnerUserId, required this.isRoomOwner, }); @@ -250,6 +273,8 @@ class _RoomHeadSnapshot { final String? roomCover; final String roomName; final String roomDisplayId; + final bool roomShowAnimatedId; + final bool roomShowGradientId; final String roomOwnerUserId; final bool isRoomOwner; @@ -263,6 +288,8 @@ class _RoomHeadSnapshot { other.roomCover == roomCover && other.roomName == roomName && other.roomDisplayId == roomDisplayId && + other.roomShowAnimatedId == roomShowAnimatedId && + other.roomShowGradientId == roomShowGradientId && other.roomOwnerUserId == roomOwnerUserId && other.isRoomOwner == isRoomOwner; } @@ -273,6 +300,8 @@ class _RoomHeadSnapshot { roomCover, roomName, roomDisplayId, + roomShowAnimatedId, + roomShowGradientId, roomOwnerUserId, isRoomOwner, ); diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart index 8d45e26..4976494 100644 --- a/lib/ui_kit/widgets/room/room_menu_dialog.dart +++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart @@ -1,5 +1,4 @@ import 'dart:ui' as ui; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -17,22 +16,28 @@ import 'package:yumi/services/audio/rtm_manager.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; import '../../../modules/index/main_route.dart'; import '../../../modules/room/voice_room_route.dart'; -import '../../../modules/store/store_route.dart'; import '../../components/sc_debounce_widget.dart'; -import '../../components/sc_tts.dart'; import '../../components/text/sc_text.dart'; class RoomMenuDialog extends StatefulWidget { - int roomMenuStime = 0; - Function(int eTime) callBack; + final int roomMenuStime; + final Function(int eTime) callBack; - RoomMenuDialog(this.roomMenuStime, this.callBack); + const RoomMenuDialog(this.roomMenuStime, this.callBack, {super.key}); @override - _RoomMenuDialogState createState() => _RoomMenuDialogState(); + State createState() => _RoomMenuDialogState(); } class _RoomMenuDialogState extends State { + static const int _menuMicManagement = 0; + static const int _menuClearMessage = 1; + static const int _menuPicture = 2; + static const int _menuSettings = 3; + static const int _menuSound = 4; + static const int _menuReport = 5; + static const int _menuBackground = 6; + List items1 = []; List items2 = []; @@ -75,7 +80,7 @@ class _RoomMenuDialogState extends State { if (Provider.of(context, listen: false).isFz()) { items2.add( RoomMenu( - 0, + _menuMicManagement, SCAppLocalizations.of(context)!.micManagement, "sc_icon_menu_mic_model_change.png", ), @@ -84,14 +89,14 @@ class _RoomMenuDialogState extends State { items2.add( RoomMenu( - 1, + _menuClearMessage, SCAppLocalizations.of(context)!.clearMessage, "sc_icon_room_msg_clear.png", ), ); items2.add( RoomMenu( - 2, + _menuPicture, SCAppLocalizations.of(context)!.picture, "sc_icon_room_msg_pic.png", ), @@ -99,7 +104,7 @@ class _RoomMenuDialogState extends State { if (Provider.of(context, listen: false).isFz()) { items2.add( RoomMenu( - 3, + _menuSettings, SCAppLocalizations.of(context)!.settings, "sc_icon_room_menu_settins.png", ), @@ -107,17 +112,26 @@ class _RoomMenuDialogState extends State { } items2.add( RoomMenu( - 4, + _menuSound, SCAppLocalizations.of(context)!.sound2, Provider.of(context, listen: false).roomIsMute ? "sc_icon_mic_mute.png" : "sc_icon_mic_open.png", ), ); + if (Provider.of(context, listen: false).isFz()) { + items2.add( + RoomMenu( + _menuBackground, + SCAppLocalizations.of(context)!.background, + "sc_icon_room_background.png", + ), + ); + } if (!Provider.of(context, listen: false).isFz()) { items2.add( RoomMenu( - 5, + _menuReport, SCAppLocalizations.of(context)!.report, "sc_icon_room_report.png", ), @@ -134,7 +148,7 @@ class _RoomMenuDialogState extends State { child: Container( width: ScreenUtil().screenWidth, height: 200.w, - color: Color(0xff09372E).withOpacity(0.5), + color: const Color(0xff09372E).withValues(alpha: 0.5), child: Column( children: [ Expanded( @@ -190,7 +204,7 @@ class _RoomMenuDialogState extends State { ], ), onTap: () async { - if (item.id == 0) { + if (item.id == _menuMicManagement) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); SmartDialog.show( tag: "showRoomMicSwitch", @@ -201,10 +215,10 @@ class _RoomMenuDialogState extends State { return RoomMicSwitchPage(); }, ); - } else if (item.id == 1) { + } else if (item.id == _menuClearMessage) { Provider.of(context, listen: false).clearMessage(); SmartDialog.dismiss(tag: "showRoomMenuDialog"); - } else if (item.id == 2) { + } else if (item.id == _menuPicture) { if (SCRoomUtils.touristCanMsg(context)) { SCPickUtils.pickImage(context, (bool success, String url) { if (success) { @@ -234,26 +248,31 @@ class _RoomMenuDialogState extends State { SmartDialog.dismiss(tag: "showRoomMenuDialog"); }, neeCrop: false); } - } else if (item.id == 3) { + } else if (item.id == _menuSettings) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); - SCNavigatorUtils.push( + VoiceRoomRoute.openRoomEdit( navigatorKey.currentState!.context, - "${VoiceRoomRoute.roomEdit}?need=false", - replace: false, + needRestCurrentRoomInfo: false, ); - } else if (item.id == 4) { + } else if (item.id == _menuSound) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); Provider.of( context, listen: false, ).toggleRemoteAudioMuteForAllUsers(); - } else if (item.id == 5) { + } else if (item.id == _menuReport) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); SCNavigatorUtils.push( navigatorKey.currentState!.context, "${SCMainRoute.report}?type=room&tageId=${Provider.of(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}", replace: false, ); + } else if (item.id == _menuBackground) { + SmartDialog.dismiss(tag: "showRoomMenuDialog"); + VoiceRoomRoute.openRoomBackgroundSelect( + navigatorKey.currentState!.context, + rootNavigator: true, + ); } }, ); diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index c0c87e3..b2fbd82 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -281,17 +281,18 @@ class _RoomUserInfoCardState extends State { false, assetPath: SCSpecialIdAssets.userId, - animationWidth: 110.w, - animationHeight: 28.w, - textPadding: - EdgeInsets.fromLTRB( - 33.w, - 6.w, - 16.w, - 6.w, - ), + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + ref + .userCardInfo + ?.userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( - color: Colors.white, + color: Colors.black, fontSize: 12.sp, fontWeight: FontWeight.bold, ), @@ -300,6 +301,7 @@ class _RoomUserInfoCardState extends State { fontSize: 12.sp, fontWeight: FontWeight.bold, ), + animationFit: BoxFit.contain, ), ], ), diff --git a/sc_images/room/sc_icon_room_background.png b/sc_images/room/sc_icon_room_background.png new file mode 100644 index 0000000000000000000000000000000000000000..dfe1b1956e9ff128b24d6a5c252cb54e4b31928f GIT binary patch literal 863 zcmV-l1EBngP)<=RV}Q4?RJoC`yok(uzWQ7INLOO9sn|%qKnzEmM61|yB%Te$ z1digZM2lE@^F-_nyJINAM>Z2nMXPuF$j0feptm2&yuMYm2xb^uz%Oon;es8A@ZXyz zY!oemkCDW0T~PR>=#4@Tir`nMS1>u(C%wj+=v0Pi0vD<$Ly@QZ*l!GPgF5<}y)rMZ zkT;pq_K_uApq`WT3oFt37+#~9oTbAg?NwXB@6|%^N$iK^T&}YRUMxlHw>Y-EnS!sy z?of?Ou`Ae%?ouZ=9^+}(u%O@@_(Q?p;0}wlkr5rT0CM44$FbP0j@w{WIcjS?Tvx_O zuaD12VtgAd>Sgw*%gcl!8DcO&d6?R=$yWs?f!H|EJqP2JSjArx8 z^!U7RZJOAULY8e~n z`_^k+Q*9?OZ#SVe##DTt?^mx0(+Q7g27{sod!aXFA`fu}sF#h6^|F%y)2H6|Nx-8z zu8{xl%q305bZ7%J#8?EE) account -> 麦位映射` 解析目标 | 保留,迁移时统一封装 | +| 飞向麦位会话去重 | 继续保留当前 `queueTag / sessionKey` 级别清理策略 | 保留 | +| 幸运礼物里程碑特效 | 继续保留当前里程碑资源集合与触发口径 | 保留 | +| 幸运礼物顶部奖励框 | 继续保留当前奖励金额展示方式 | 保留 | +| 幸运礼物 burst 条件 | 继续保持“倍率 `>= 10x` 或单次奖励 `> 5000`” | 保留 | +| 幸运礼物飘屏口径 | 继续保持当前服务端 `globalNews` + 倍率门槛逻辑 | 保留 | +| 大额礼物房间飘屏阈值 | 继续保持当前 `coins > 9999` 才触发房间礼物飘屏 | 保留 | +| 礼物触发麦位刷新 | 继续保留礼物触发后的节流刷新逻辑 | 保留,迁移时挪到 effect bridge 或 event side-effect 层 | +| 房间进场横幅 | 继续保留当前房内进场 banner 表达方式 | 保留视觉,不一定保留旧实现 | + +### 建议保留但从硬编码改为配置化的参数 + +- 礼物播报槽位数 +- 礼物播报待播上限 +- 进场队列上限 +- 飞向麦位单会话上限 +- 高成本特效队列上限 +- 飘屏丢弃阈值 +- 各类优先级与独占关系 + +--- + +## 目标架构 + +### 架构总览 + +```mermaid +graph TD + A[RTM/RTC/房间本地动作] --> B[RoomEffectEventAdapter] + B --> C[RoomEffectEngine] + C --> D[EffectPolicy] + C --> E[Dedup & Merge] + C --> F[Lane Dispatcher] + F --> G[Fullscreen Lane] + F --> H[Entrance Lane] + F --> I[GiftTicker Lane] + F --> J[SeatFlight Lane] + F --> K[Floating Lane] + G --> L[RoomEffectStage] + H --> L + I --> L + J --> L + K --> L + L --> M[SVGA/VAP/Image/Implicit Animations] + C --> N[Metrics & Trace] +``` + +### 核心思想 + +- 所有原始事件先进入 `RoomEffectEventAdapter` +- adapter 只负责把“原始 RTM/RTC/本地动作”变成**标准化特效事件** +- 所有标准化事件再进入 `RoomEffectEngine` +- engine 统一做去重、合并、优先级排序、预算判断、降级和分 lane 调度 +- 真正的渲染层只负责展示,不再关心业务来源 + +--- + +## 推荐模块拆分图 + +建议新增一个独立的语言房特效模块,命名可为 `lib/modules/room_effect/` 或 `lib/shared/room_effect/`。 + +```text +lib/ + modules/ + room_effect/ + domain/ + room_effect_event.dart + room_effect_task.dart + room_effect_lane.dart + room_effect_priority.dart + room_effect_policy_snapshot.dart + application/ + room_effect_event_adapter.dart + room_effect_engine.dart + room_effect_deduplicator.dart + room_effect_merger.dart + room_effect_policy.dart + room_effect_metrics.dart + presentation/ + room_effect_stage.dart + layers/ + room_fullscreen_effect_layer.dart + room_entrance_effect_layer.dart + room_gift_ticker_layer.dart + room_seat_flight_layer.dart + room_floating_effect_layer.dart + infrastructure/ + room_effect_asset_preloader.dart + room_effect_player_bridge.dart + room_effect_lifecycle_guard.dart +``` + +### 各模块职责 + +| 模块 | 职责 | 说明 | +| --- | --- | --- | +| `RoomEffectEventAdapter` | 事件归一化 | 把 `RTM/RTC/RoomPage` 的原始数据转为统一事件对象 | +| `RoomEffectEngine` | 统一调度核心 | 做入队、去重、合并、优先级、预算、丢弃、lane 分发 | +| `RoomEffectPolicy` | 播放策略 | 根据机型、开关、 backlog、当前播放状态决定播什么、延后什么、降级什么 | +| `RoomEffectStage` | 页面内特效承载层 | 只在语言房路由内部挂载,房间退出就一并销毁 | +| `PlayerBridge` | 播放器桥接层 | 首轮复用现有 `SCGiftVapSvgaManager` / `GiftAnimationManager` / 现有动画组件 | +| `AssetPreloader` | 资源预热 | 统一预热图片、SVGA、VAP,本轮不再让每个特效组件各自处理 | +| `Metrics` | 指标与日志 | 记录入队数、丢弃数、等待时长、播放耗时、异常和 backlog | + +--- + +## 目标事件模型 + +建议统一定义一个标准化事件对象 `RoomEffectEvent`,至少包含以下信息: + +- `eventId` +- `roomId` +- `eventType` +- `source` +- `createdAt` +- `priority` +- `dedupKey` +- `mergeKey` +- `userId` +- `targetUserId` +- `giftId` +- `payload` +- `requiresHeavyRenderer` +- `roomScoped` + +### 推荐事件类型 + +- `roomUserJoin` +- `roomMountEntrance` +- `giftTicker` +- `giftFullscreen` +- `giftSeatFlight` +- `giftFloating` +- `luckyGiftMilestone` +- `luckyGiftRewardTicker` +- `luckyGiftBurst` +- `rocketFloating` +- `redPacketFloating` +- `gameFloating` + +### 为什么要有标准化事件层 + +- 后续所有去重和合并都建立在统一事件模型上 +- 可把“同一礼物事件触发 3 种视觉表现”拆成 3 个子任务,而不是让 RTM 直接调用 3 个 UI 系统 +- 可支持本地自发事件和远端回流事件的统一去重 + +--- + +## 目标任务模型 + +事件进入引擎后,不直接播放,而是先变成 `RoomEffectTask`。 + +一个 task 建议至少包含: + +- `taskId` +- `lane` +- `priority` +- `enqueueAt` +- `deadline` +- `exclusive` +- `mergeable` +- `mergeKey` +- `dropPolicy` +- `payload` +- `onStart` +- `onComplete` +- `onCancel` + +### 推荐的 lane 划分 + +| Lane | 作用 | 特点 | +| --- | --- | --- | +| `fullscreen` | 全屏礼物、幸运礼物 burst、进场坐骑等高成本特效 | 可独占,优先级最高 | +| `entrance` | 房间进场 banner | 串行,可合并,可被延后 | +| `giftTicker` | 顶部礼物播报条 | 维持 4 槽位模型 | +| `seatFlight` | 飞向麦位 | 可批量、可会话级去重 | +| `floating` | 房间飘屏、幸运礼物飘屏、火箭、红包、游戏 | 可按消息类型和房间范围过滤 | + +--- + +## 页面挂载方式调整 + +### 当前问题 + +当前房间特效有一部分挂在根层 `MaterialApp builder`,例如: + +- 全屏 `VapPlusSvgaPlayer` +- `RoomGiftSeatFlightOverlay` + +这意味着: + +- 特效承载层不完全属于房间页面 +- 房间退出后需要额外兜底清理 +- 根层 overlay 容易被误复用到非房间页面 + +### 目标方式 + +推荐新增 `RoomEffectStage`,只挂在语言房页面内: + +```mermaid +graph TD + A[VoiceRoomPage] --> B[RoomScene] + A --> C[RoomEffectStage] + C --> D[Fullscreen Layer] + C --> E[Entrance Layer] + C --> F[GiftTicker Layer] + C --> G[SeatFlight Layer] + C --> H[Floating Layer] +``` + +### 结果 + +- 房间特效生命周期跟随 `VoiceRoomPage` +- 退房、最小化、切房时统一收口 +- 根层只保留真正全局的东西,房间特效不再越界 + +--- + +## 首轮推荐复用现有实现的方式 + +为尽量保留当前礼物逻辑,首轮不需要把所有 renderer 重写,可以先“新引擎 + 旧 renderer”。 + +### 可复用项 + +| 现有实现 | 首轮建议 | +| --- | --- | +| `SCGiftVapSvgaManager` | 作为 `fullscreen lane` 的底层播放器桥接 | +| `GiftAnimationManager` | 作为 `giftTicker lane` 的底层 4 槽位实现 | +| `RoomGiftSeatFlightOverlay` | 作为 `seatFlight lane` 的底层渲染器 | +| `RoomAnimationQueueScreen` | 其视觉可复用,但队列控制迁到新引擎 | +| `OverlayManager` | 首轮可继续承载非房间全局类型;房间类型逐步迁出 | + +### 不建议继续保留为长期方案的实现 + +| 现有实现 | 建议 | +| --- | --- | +| `room_entrance_widget.dart` 的旧 helper/queue | 清理掉,不再作为正式链路 | +| 在 `RTM/RTC` 中直接 `play()` 或 `addMessage()` | 改为统一投递特效事件 | +| 在根层 `MaterialApp builder` 挂房间专属特效层 | 迁回房间路由内部 | + +--- + +## 调度与降级策略 + +### 首轮策略 + +首轮重构不建议改业务口径,但建议统一调度策略。 + +#### 建议规则 + +- `fullscreen lane` 播放时,`entrance / floating / seatFlight` 默认延后 +- `giftTicker lane` 继续独立展示,但只保留当前 4 槽位,不无限堆积 +- `seatFlight lane` 保持当前 `customAnimationCount` 逻辑,但由引擎统一裁剪上限 +- `entrance lane` 超过 backlog 后可合并为“X 人进入房间” +- `floating lane` 在高峰时优先保留高优先级类型,低优先级直接丢弃 + +### 第二阶段可再加的自适应策略 + +- backlog 超阈值时,把同礼物多次飞向麦位降成 1 次飞行 + 数量角标 +- 进场队列过长时,多条普通进场合并成一条摘要 +- 同时存在全屏高成本特效时,房间礼物飘屏暂停或只保留 1 条 +- 低性能设备继续保留现有总开关,但高性能设备也可按瞬时 backlog 动态降级 + +--- + +## 迁移顺序 + +建议按“先收口入口,再迁移渲染,再清理历史代码”的顺序做,避免一口气全动。 + +### 阶段 0:建立基线与指标 + +目标: + +- 先补齐特效侧可观测性 +- 固化当前礼物逻辑和关键参数,形成“迁移不改口径”的基线 + +本阶段内容: + +- 统计每类特效的入队数、丢弃数、平均等待时长、播放时长 +- 统计 `fullscreen/entrance/giftTicker/seatFlight/floating` 的 backlog 峰值 +- 用 `FrameTiming` 记录语言房高压场景的 build/raster 情况 + +本阶段不改: + +- 不改现有播放逻辑 +- 不改现有视觉 +- 不改现有业务阈值 + +### 阶段 1:统一事件入口 + +目标: + +- 引入 `RoomEffectEventAdapter` +- 所有房间特效先变成统一事件对象 + +本阶段内容: + +- 把 `RTM/RTC/VoiceRoomPage/GiftSystem` 里直接触发特效的地方改为“投递事件” +- 本地触发与远端回流先统一走一层去重 key + +本阶段不改: + +- renderer 先不迁 +- `SCGiftVapSvgaManager`、`GiftAnimationManager` 等先照旧使用 + +关键收益: + +- 从这一阶段开始,特效来源就被收口了 + +### 阶段 2:落地 `RoomEffectEngine` + +目标: + +- 让所有统一事件进入引擎 +- 引擎接管优先级、去重、合并和 lane 分发 + +本阶段内容: + +- 新建 `RoomEffectEngine` +- 新建 lane 模型 +- 先做不改变视觉的“旧 renderer 包装” + +本阶段不改: + +- 不改具体动画 UI +- 不改素材 + +关键收益: + +- 从“多套队列系统”变成“一个总引擎 + 多条 lane” + +### 阶段 3:把房间特效挂回房间路由内部 + +目标: + +- 新建 `RoomEffectStage` +- 把房间专属层从根层迁回 `VoiceRoomPage` + +本阶段内容: + +- `RoomEffectStage` 承载房间内所有特效 layer +- 根层只保留真正全局的内容 +- 统一退房清理、最小化清理、切房清理 + +本阶段重点回归: + +- 房间退出后不再有礼物图残留 +- 房间飘屏不再跑到首页 + +### 阶段 4:迁移 `fullscreen lane` + +目标: + +- 先把最高成本、影响最大的特效链路收口 + +本阶段内容: + +- `SCGiftVapSvgaManager` 改为由 `fullscreen lane` 调用 +- 进场坐骑、全屏礼物、幸运礼物 burst 都走统一 lane +- 自带独占、优先级和预热策略 + +本阶段必须保持不变: + +- 当前全屏礼物触发口径不变 +- 当前幸运礼物里程碑和 burst 口径不变 + +### 阶段 5:迁移 `entrance lane` + +目标: + +- 收口房间进场 banner +- 清理旧进场链路 + +本阶段内容: + +- `RoomAnimationQueueScreen` 只保留渲染,队列逻辑迁到 `entrance lane` +- 清理 `room_entrance_widget.dart` 旧 helper 及残余引用 +- 引入本地与远端进场事件去重 + +本阶段必须保持不变: + +- 当前进场 UI 表达不变 +- 当前入场后坐骑与横幅仍可共存,但由统一引擎调度 + +### 阶段 6:迁移 `giftTicker lane` + +目标: + +- 保留当前 4 槽位礼物播报条逻辑,但把队列和状态收回引擎 + +本阶段内容: + +- `GiftAnimationManager` 改为 lane 内 renderer bridge +- 继续保留当前 `labelId` 合并、4 槽位、空闲消失时间、数量步进动画 +- 幸运礼物奖励框仍保留当前样式 + +本阶段必须保持不变: + +- 连击合并口径 +- `customAnimationCount` 对数量展示的影响 +- 幸运礼物奖励框逻辑 + +### 阶段 7:迁移 `seatFlight lane` + +目标: + +- 把飞向麦位从独立控制器改为统一 lane 管理 + +本阶段内容: + +- 复用现有 `RoomGiftSeatFlightOverlay` 作为渲染层 +- `queueTag/sessionKey` 的批量裁剪和清理迁入 engine +- 目标麦位 key 解析改成统一桥接 + +本阶段必须保持不变: + +- 当前会话级去重逻辑 +- 当前 `customAnimationCount` 粒度 +- 当前图片预热行为 + +### 阶段 8:迁移 `floating lane` + +目标: + +- 收口房间礼物飘屏、幸运礼物飘屏、火箭、红包、游戏飘屏 + +本阶段内容: + +- 房间内的 floating 类型全部走 `floating lane` +- 真正全局广播的类型与房间类型分开 +- `OverlayManager` 只保留全局用法,房间类型从中剥离 + +本阶段必须保持不变: + +- 幸运礼物 `globalNews` 逻辑不变 +- 房间礼物 `coins > 9999` 才飘屏的逻辑不变 + +### 阶段 9:清理历史实现与补充自适应降级 + +目标: + +- 删掉双轨实现 +- 把当前“局部优化痕迹”收成统一结构 + +本阶段内容: + +- 删除旧进场 helper +- 删除 `RTM/RTC` 中直接 `play()` 和 `addMessage()` 的旧入口 +- 完成 backlog 降级策略与观测面板 + +--- + +## 关键兼容策略 + +### 1. 先换“控制面”,不先换“渲染面” + +首轮不要把所有动画组件重写掉。 + +推荐策略: + +- 新引擎先接管“什么时候播、播哪条、谁先谁后、是否丢弃” +- 旧组件先继续负责“怎么画出来” + +这样做的好处: + +- 视觉回归风险小 +- 礼物逻辑更容易保持一致 +- 每一阶段都可以独立回滚 + +### 2. 消息协议不动 + +当前房间消息、礼物消息、幸运礼物消息结构先不改。 + +这样可以: + +- 避免前后端联动阻塞 +- 避免把“性能问题”变成“协议问题” + +### 3. 参数冻结后再迁移 + +建议先把以下参数冻结成一份配置文档或常量表: + +- 幸运礼物里程碑列表 +- 幸运礼物 burst 条件 +- 房间礼物飘屏阈值 +- 顶部礼物播报条槽位与时长 +- 飞向麦位上限 +- 进场队列上限 + +这样迁移时只改结构,不改结果。 + +--- + +## 风险点列表 + +| 风险点 | 说明 | 风险等级 | 缓解方式 | +| --- | --- | --- | --- | +| 本地事件与远端回流重复 | 自己送礼、自己进房、进场坐骑等容易双触发 | 高 | 引入 `dedupKey`,按 `roomId + type + sender + target + gift + timeWindow` 去重 | +| 去重过度导致特效漏播 | 某些高频连击若 key 设计过粗,会把应播事件吃掉 | 高 | 区分 `dedupKey` 和 `mergeKey`,先留日志,初期采用保守去重 | +| 房间退出后仍有旧特效回调 | `postFrame / delayed / timer / precache` 都可能晚到 | 高 | 所有 task 和 layer 引入 `sessionToken` 或 `roomLifecycleToken` 校验 | +| 渲染层迁移后层级错乱 | 根层迁到房间层后,z-order 可能改变 | 中 | 在 `RoomEffectStage` 中明确 layer 顺序,先对齐现有视觉层级 | +| 资源预热过多导致内存抖动 | 统一预热后若没有预算控制,容易顶高内存 | 中 | 按 lane、资源类型、尺寸做分层缓存和 LRU | +| 幸运礼物口径漂移 | 这块逻辑复杂,回归风险高 | 高 | 首轮冻结参数与触发口径,不做业务变更 | +| 飞向麦位目标丢失 | 目标座位 GlobalKey 尚未可用时可能丢动画 | 中 | 保留当前重试机制,但统一纳入 lane,限制重试次数 | +| 飘屏房间范围判断错误 | 房间飘屏与全局飘屏容易混 | 中 | 明确 `roomScoped` 与 `globalScoped` 两类事件 | +| 迁移阶段代码并存时间过长 | 双轨逻辑存在越久,后续越难收口 | 中 | 每一阶段都指定“完成后可删”的旧文件或旧入口 | +| 测试面过大 | 礼物、幸运礼物、火箭、红包、进场、坐骑交叉很多 | 高 | 用阶段化回归清单,按 lane 逐项验收 | + +--- + +## 回归测试清单 + +### 礼物链路 + +- 单次送礼 1 次 +- 同一礼物连续送 5 次、10 次 +- `customAnimationCount > 1` 的批量场景 +- 幸运礼物普通中奖 +- 幸运礼物高倍率中奖 +- 同时出现全屏礼物 + 顶部播报条 + 飞向麦位 + +### 进场链路 + +- 自己进入房间 +- 别人进入房间 +- 别人带坐骑进入房间 +- 同时 5 人以上快速进入 + +### 飘屏链路 + +- 房间礼物飘屏 +- 幸运礼物房间飘屏 +- 全局广播幸运礼物飘屏 +- 火箭飘屏 +- 红包飘屏 + +### 生命周期 + +- 房间内最小化 +- 退出房间 +- 切到首页/消息/我的 +- 从 A 房切到 B 房 + +### 性能 + +- 普通机型连续送礼 10 秒 +- 低性能机型连续送礼 10 秒 +- 进场和礼物叠加压测 +- 房间内停留 10 分钟观察缓存与内存变化 + +--- + +## 验收指标建议 + +建议在重构过程中记录以下指标,避免“感觉快了”但没有客观标准: + +- 语言房高压场景下 `FrameTiming` 的 `build/raster` P95 +- `fullscreen lane` 平均等待时长 +- `entrance/floating/seatFlight` backlog 峰值 +- 被丢弃任务数量和占比 +- 同事件重复触发数 +- 房间退出后残留特效数 + +### 目标方向 + +- 房间高压场景下明显减少整页卡顿和掉帧 +- 不再出现房间特效溢出到首页 +- 不再依赖在多个管理器里各自补清理逻辑 +- 新增一个特效类型时,只需要走“标准化事件 -> lane -> layer”链路 + +--- + +## 预计实施节奏 + +如果只重构语言房特效子系统,不推倒整套房间业务,建议节奏如下: + +| 阶段 | 预估工作量 | +| --- | --- | +| 阶段 0 ~ 2 | 2 到 3 天 | +| 阶段 3 ~ 5 | 4 到 6 天 | +| 阶段 6 ~ 8 | 4 到 6 天 | +| 阶段 9 与回归 | 2 到 3 天 | + +整体建议: + +- 保守估计约 `2` 周 +- 如果中途顺手一起重构 `RTC/RTM/RoomPage` 主业务结构,工期会明显变长 + +--- + +## 多线程并行执行方案 + +如果后续不是单线程顺着做,而是你准备把这次重构拆成多个并行线程分别跑,建议采用**“3 个主线程并行 + 1 个收尾线程串行收口”**的方案。 + +### 并行收益预估 + +按“最稳的预期”估算: + +- 单线程完整落地:约 `20 到 24` 小时 +- 多线程并行后:约 `14 到 16` 小时 + +这里的前提是: + +- 先冻结接口命名和模块边界 +- 尽量避免多个线程同时改同一批现有文件 +- 最后仍保留一个统一收尾和回归线程 + +不建议的并行方式: + +- 不建议 3 个线程同时直接改 `rtm_manager.dart / rtc_manager.dart / voice_room_page.dart` +- 不建议一边改 engine,一边让多个线程各自发明自己的事件模型 +- 不建议一开始就多个线程同时删旧链路 + +### 并行原则 + +1. 先统一接口,再并行开发 + 至少先约定好以下对象名和职责: + + - `RoomEffectEvent` + - `RoomEffectTask` + - `RoomEffectLane` + - `RoomEffectEngine` + - `RoomEffectStage` + - `RoomEffectEventAdapter` + +2. 新文件优先并行,旧文件尽量收口到单一线程 + 新增模块最适合多线程拆分;旧有入口文件应尽量减少多人同时修改。 + +3. 先搭骨架,再接现有链路 + 先让 engine 与 stage 的框架稳定,再开始把 `RTM/RTC/VoiceRoomPage` 接上去。 + +4. 收尾一定要串行 + 旧链路删除、层级调整、房间生命周期回收、回归修 bug,这些必须收口到最后一个线程统一处理。 + +--- + +### 推荐线程划分 + +#### 线程 A:Engine 基础层 + +定位: + +- 负责“语言房特效引擎”的核心模型与调度骨架 + +推荐负责范围: + +- `lib/modules/room_effect/domain/` +- `lib/modules/room_effect/application/` +- `lib/modules/room_effect/infrastructure/` 中和调度、预热、生命周期守卫相关的文件 + +推荐文件所有权: + +- `room_effect_event.dart` +- `room_effect_task.dart` +- `room_effect_lane.dart` +- `room_effect_priority.dart` +- `room_effect_engine.dart` +- `room_effect_policy.dart` +- `room_effect_deduplicator.dart` +- `room_effect_merger.dart` +- `room_effect_metrics.dart` +- `room_effect_asset_preloader.dart` +- `room_effect_lifecycle_guard.dart` + +本线程不建议碰: + +- `rtm_manager.dart` +- `rtc_manager.dart` +- `voice_room_page.dart` +- 现有具体动画 widget 的渲染实现 + +本线程交付物: + +- 可编译的 engine/domain/application 基础骨架 +- lane、task、event、policy 的统一接口 +- 基本的 backlog、优先级、去重、合并接口 + +#### 线程 B:Stage 与渲染承载层 + +定位: + +- 负责“语言房特效舞台”和各 layer 的承载结构 + +推荐负责范围: + +- `lib/modules/room_effect/presentation/` + +推荐文件所有权: + +- `room_effect_stage.dart` +- `layers/room_fullscreen_effect_layer.dart` +- `layers/room_entrance_effect_layer.dart` +- `layers/room_gift_ticker_layer.dart` +- `layers/room_seat_flight_layer.dart` +- `layers/room_floating_effect_layer.dart` +- 如果需要,可新增 presentation 侧 controller 或 snapshot 文件 + +本线程可复用但不建议大改的旧文件: + +- `lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart` +- `lib/ui_kit/widgets/room/anim/room_entrance_screen.dart` +- `lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart` +- `lib/ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart` + +建议策略: + +- 先做 layer 容器和接口桥接,不先重写底层动画实现 +- 首轮允许通过 bridge 调现有 renderer + +本线程不建议碰: + +- `rtm_manager.dart` +- `rtc_manager.dart` +- `main.dart` + +本线程交付物: + +- `RoomEffectStage` +- 各特效 layer 的挂载顺序和层级 +- 渲染层与 engine 的消费接口 + +#### 线程 C:旧链路入口收口与适配层 + +定位: + +- 负责把现有 `RTM/RTC/VoiceRoomPage/GiftSystem` 的特效触发点统一改成事件投递 + +推荐负责范围: + +- `lib/services/audio/rtm_manager.dart` +- `lib/services/audio/rtc_manager.dart` +- `lib/modules/room/voice_room_page.dart` +- `lib/services/gift/gift_system_manager.dart` +- 适配层文件,例如: + - `lib/modules/room_effect/application/room_effect_event_adapter.dart` + - 或单独的 bridge/wiring 文件 + +本线程重点任务: + +- 找出所有直接 `play()`、`OverlayManager().addMessage()`、直接入队的触发点 +- 改为投递标准化事件 +- 做本地触发和远端回流的去重 key +- 保持现有礼物业务逻辑不变 + +本线程不建议碰: + +- `RoomEffectStage` 内部结构 +- 各 layer 的视觉实现 +- 大量历史文件删除 + +本线程交付物: + +- 统一事件入口 +- `RTM/RTC` 不再直接知道“怎么播动画” +- 旧业务逻辑已迁到 adapter/event 侧表达 + +#### 线程 D:收尾、清理与回归 + +定位: + +- 在 A/B/C 合并后串行执行,不建议一开始就启动 + +推荐负责范围: + +- `lib/main.dart` +- `lib/modules/room/voice_room_page.dart` +- `lib/shared/data_sources/sources/local/floating_screen_manager.dart` +- `lib/shared/tools/sc_room_effect_scheduler.dart` +- `lib/ui_kit/widgets/room/anim/room_entrance_widget.dart` +- 其它需要删除旧逻辑、改挂载层级、补回归日志的文件 + +本线程重点任务: + +- 把房间专属特效层从根层尽量收回房间页内部 +- 清理旧进场 helper 和双轨实现 +- 清理失效入口 +- 跑一轮回归,修明显联调问题 + +--- + +### 最稳的执行顺序 + +推荐按下面顺序跑,而不是 4 个线程同时无序开工。 + +#### 第 1 步:接口冻结 + +由任意一个线程先完成,建议控制在 `0.5 到 1` 小时内。 + +需要先冻结的内容: + +- `RoomEffectEvent` 字段 +- `RoomEffectTask` 字段 +- lane 列表 +- engine 对外接口 +- stage 对外接口 +- adapter 的输入输出职责 + +这一步完成后,线程 A/B/C 才正式并行。 + +#### 第 2 步:A/B/C 三线程并行 + +并行期间建议这样分: + +- A 只写 engine/domain/application 基础层 +- B 只写 stage/presentation/layer 容器 +- C 只写现有旧入口收口和事件适配 + +并行期间禁止多人同时改的文件: + +- `lib/services/audio/rtm_manager.dart` +- `lib/services/audio/rtc_manager.dart` +- `lib/modules/room/voice_room_page.dart` +- `lib/main.dart` + +这些文件建议默认归线程 C 或线程 D 所有。 + +#### 第 3 步:先合 A,再合 B,再合 C + +推荐合并顺序: + +1. 先合线程 A + 先把 engine 和模型定住,避免后续接口继续漂。 + +2. 再合线程 B + 让 stage 和 layer 按 A 的接口接上。 + +3. 再合线程 C + 最后把现有旧入口真正接到 engine 上。 + +这样做的原因: + +- 可以最大程度减少 `A/B/C` 三方接口对撞 +- 让旧链路接入时面对的是已经稳定的 engine 与 stage + +#### 第 4 步:最后由线程 D 串行收尾 + +线程 D 再做这些事: + +- 根层与房间层的挂载调整 +- 删除旧实现 +- 生命周期清理 +- 房间退出和切房回归 +- backlog 观测和简单日志补充 + +--- + +### 推荐的文件写入边界 + +为了减少合并冲突,建议严格遵守下面的写入边界。 + +| 线程 | 允许重点修改 | 尽量不要修改 | +| --- | --- | --- | +| A | `lib/modules/room_effect/domain/`、`application/`、`infrastructure/` 新文件 | 现有房间业务文件 | +| B | `lib/modules/room_effect/presentation/` 新文件 | `rtm_manager.dart`、`rtc_manager.dart` | +| C | `rtm_manager.dart`、`rtc_manager.dart`、`voice_room_page.dart`、`gift_system_manager.dart`、adapter | `main.dart`、presentation 新文件的大结构 | +| D | `main.dart`、旧 helper 清理、根层挂载调整、遗留逻辑删除 | engine 核心接口 | + +--- + +### 推荐的并行里程碑 + +建议把并行执行拆成 4 个里程碑,每个里程碑都要求能单独检查。 + +#### 里程碑 1 + +- A:engine/domain/task/lane 骨架完成 +- B:stage/layer 容器骨架完成 +- C:梳理所有旧触发点,列出事件映射表 + +#### 里程碑 2 + +- A:lane 调度、去重、合并接口可用 +- B:各 layer 能消费统一 task 或 snapshot +- C:`RTM/RTC/VoiceRoomPage` 已改成先投递事件,不直接触发动画 + +#### 里程碑 3 + +- A/B:engine 与 stage 已能联通 +- C:主要礼物/进场/飘屏入口已接新引擎 + +#### 里程碑 4 + +- D:旧链路清理、房间退出/切房回归完成 + +--- + +### 不同并行强度的建议 + +#### 最稳方案 + +- `3` 个主线程并行 +- `1` 个收尾线程后置串行 + +适用场景: + +- 你希望尽量减少互相撞车 +- 你更在意一次性落稳,而不是追求最短墙钟时间 + +#### 更激进方案 + +- `4` 个线程一开始就并行 + +不太建议,原因是: + +- `main.dart` +- `voice_room_page.dart` +- `rtm_manager.dart` +- `rtc_manager.dart` + +这些文件很容易形成高冲突区,激进并行最后未必更快。 + +--- + +### 最终建议的并行执行口径 + +如果你后面准备“分别跑”多个线程,建议直接按下面口径执行: + +- 线程 A:只做 engine/domain/application 基础层 +- 线程 B:只做 stage/presentation/layer 承载层 +- 线程 C:只做旧入口收口和事件适配 +- 线程 D:最后统一清理旧链路、改挂载、跑回归 + +这是这次语言房特效系统重构里,**最稳、最不容易互相打架**的一种并行拆法。 + +--- + +## 可直接投喂 Codex 的执行 Prompts + +下面这组 prompt 是给你后面开多个 Codex 线程时直接复制使用的。 + +推荐使用方式: + +1. 先把“公共约定 prompt”复制给每个线程 +2. 再把对应线程的专属 prompt 追加给该线程 +3. 线程 D 不要一开始就跑,等 A/B/C 合并后再启动 + +### 公共约定 Prompt + +建议把下面这段先发给每一个线程,作为共同上下文。 + +```text +你现在在 /Users/nigger/Documents/GitHub/chatapp3-flutter 仓库中工作。 + +当前目标不是重构整个语言房,而是优先重构“语言房特效子系统”。 +请先阅读根目录文档: +- /Users/nigger/Documents/GitHub/chatapp3-flutter/语言房重构.md + +本轮必须遵守这些硬约束: + +1. 尽量保留当前礼物业务逻辑、阈值和视觉表现,不要擅自改业务口径。 +2. 不要改房间消息协议,不要改后端接口。 +3. 只在你被授权的写入范围内改文件,不要碰其它线程的主要负责文件。 +4. 不要大规模格式化无关文件,不要顺手重构无关模块。 +5. 如果你发现某个接口命名需要和其它线程统一,请优先遵守本文档中的统一命名,不要自行发明第二套。 + +统一命名约定如下: + +- RoomEffectEvent +- RoomEffectTask +- RoomEffectLane +- RoomEffectPriority +- RoomEffectEngine +- RoomEffectPolicy +- RoomEffectEventAdapter +- RoomEffectStage + +统一 lane 命名如下: + +- fullscreen +- entrance +- giftTicker +- seatFlight +- floating + +必须保留的当前关键逻辑: + +- 顶部礼物播报条继续保持 4 个槽位 +- 顶部礼物播报条待播上限继续按当前 24 处理 +- 顶部礼物播报条空闲消失时长继续保持约 3200ms +- 飞向麦位继续保留 customAnimationCount 的粒度逻辑 +- 幸运礼物 burst 继续保持“倍率 >= 10x 或单次奖励 > 5000” +- 房间礼物飘屏继续保持 coins > 9999 才触发 +- 继续沿用当前特效总开关: + - SCGlobalConfig.isGiftSpecialEffects + - SCGlobalConfig.isEntryVehicleAnimation + - SCGlobalConfig.isFloatingAnimationInGlobal + +完成后请在回复中明确给出: + +- 改了哪些文件 +- 你负责范围内已经完成了什么 +- 还依赖其它线程提供什么 +- 有没有你刻意没有动的风险点 +``` + +--- + +### 线程 A Prompt + +用途: + +- 负责 engine/domain/application 基础层 + +直接投喂: + +```text +在遵守“公共约定 prompt”的前提下,执行线程 A 的任务。 + +你的职责是:搭建语言房特效系统的 engine/domain/application 基础层,只负责新架构骨架,不接老的 RTM/RTC 入口,不改现有房间业务触发逻辑。 + +你的主要目标: + +1. 新增语言房特效模块基础目录,优先建议放在: + - lib/modules/room_effect/domain/ + - lib/modules/room_effect/application/ + - lib/modules/room_effect/infrastructure/ + +2. 建立并实现以下核心对象或等价对象: + - RoomEffectEvent + - RoomEffectTask + - RoomEffectLane + - RoomEffectPriority + - RoomEffectPolicy + - RoomEffectEngine + - RoomEffectDeduplicator + - RoomEffectMerger + - RoomEffectMetrics + - RoomEffectLifecycleGuard + - RoomEffectAssetPreloader + +3. engine 至少要具备这些能力: + - 接收 RoomEffectEvent + - 转换为 RoomEffectTask + - 按 lane 分发 + - 基于 priority 排序 + - 留出去重、合并、丢弃、延后、独占的接口 + - 能产出每个 lane 的可消费状态或流 + +4. 你可以先做“最小可用骨架”,但不要只写空壳文件。至少保证接口能表达真实系统能力。 + +5. 你需要把这些现有业务约束沉淀到 policy 或 constants 中,但不要改业务阈值: + - giftTicker 4 槽位 + - pending 24 + - idle dismiss 3200ms + - burst 条件 + - 飘屏阈值 + +你的明确写入范围: + +- 可以新增和修改: + - lib/modules/room_effect/domain/** + - lib/modules/room_effect/application/** + - lib/modules/room_effect/infrastructure/** + +你的禁止写入范围: + +- 不要修改: + - lib/services/audio/rtm_manager.dart + - lib/services/audio/rtc_manager.dart + - lib/modules/room/voice_room_page.dart + - lib/main.dart + - lib/ui_kit/widgets/room/** 的现有渲染文件 + +你的产出要求: + +- 代码必须可编译到“接口层面合理” +- 保持命名稳定,方便线程 B/C 对接 +- 在最终回复里列出你定义的关键接口签名和文件列表 +``` + +--- + +### 线程 B Prompt + +用途: + +- 负责 `RoomEffectStage` 和 presentation/layer 承载层 + +直接投喂: + +```text +在遵守“公共约定 prompt”的前提下,执行线程 B 的任务。 + +你的职责是:搭建语言房特效系统的 presentation/stage/layer 承载层,重点是把房间内特效层级和挂载方式结构化,但不要改旧的 RTM/RTC 入口文件。 + +你的主要目标: + +1. 新增 presentation 模块,优先建议放在: + - lib/modules/room_effect/presentation/ + - lib/modules/room_effect/presentation/layers/ + +2. 建立并实现以下对象或等价对象: + - RoomEffectStage + - RoomFullscreenEffectLayer + - RoomEntranceEffectLayer + - RoomGiftTickerEffectLayer + - RoomSeatFlightEffectLayer + - RoomFloatingEffectLayer + +3. 你的核心任务不是重写所有动画,而是先把“承载层和消费接口”搭好。首轮允许通过 bridge 复用现有 renderer。 + +4. 你需要明确 layer 的顺序和职责,建议至少能表达: + - fullscreen 层 + - entrance 层 + - giftTicker 层 + - seatFlight 层 + - floating 层 + +5. 你可以为每个 layer 设计最小消费接口,例如: + - 接收 RoomEffectTask 列表 + - 接收 lane snapshot + - 接收 controller / stream / notifier + +6. 你需要尽量让 stage 未来可以挂回 VoiceRoomPage 内部,但本线程先不要大改 voice_room_page.dart。 + +你的明确写入范围: + +- 可以新增和修改: + - lib/modules/room_effect/presentation/** + +- 如有必要,可少量只读参考: + - lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart + - lib/ui_kit/widgets/room/anim/room_entrance_screen.dart + - lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart + - lib/ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart + +你的禁止写入范围: + +- 不要修改: + - lib/services/audio/rtm_manager.dart + - lib/services/audio/rtc_manager.dart + - lib/modules/room/voice_room_page.dart + - lib/main.dart + - 线程 A 负责的新 engine/domain/application 文件 + +你的产出要求: + +- 交付可读、清晰的 RoomEffectStage 与 layer 结构 +- 接口命名必须对齐公共约定 +- 在最终回复中列出: + - 你新增的 layer 文件 + - 每个 layer 准备承接哪类任务 + - 还依赖线程 A 提供哪些 engine 能力 +``` + +--- + +### 线程 C Prompt + +用途: + +- 负责旧链路入口收口与适配层 + +直接投喂: + +```text +在遵守“公共约定 prompt”的前提下,执行线程 C 的任务。 + +你的职责是:把现有语言房中的特效触发入口统一改成“事件投递”,尽量不碰 presentation 层结构,不大改渲染细节。 + +你的主要目标: + +1. 梳理并改造当前这些文件里的直接特效触发点: + - lib/services/audio/rtm_manager.dart + - lib/services/audio/rtc_manager.dart + - lib/modules/room/voice_room_page.dart + - lib/services/gift/gift_system_manager.dart + +2. 找出以下直接触发方式并逐步收口: + - 直接 SCGiftVapSvgaManager().play(...) + - 直接 OverlayManager().addMessage(...) + - 直接往独立动画队列入队 + - 页面层自己拼装多个动画副作用 + +3. 你的目标不是立刻删掉所有旧逻辑,而是先把“触发入口”统一改成投递 RoomEffectEvent 或等价事件。 + +4. 你需要新增或实现适配层,建议命名: + - RoomEffectEventAdapter + - 或等价 wiring 文件 + +5. 需要特别注意去重场景: + - 本地自己进房 + 群消息回流 + - 本地自己送礼 + 房间消息回流 + - 幸运礼物广播 + 房间群消息双到达 + +6. 必须保留当前礼物业务判断口径,不要修改: + - 哪些礼物触发全屏 + - 哪些礼物触发飘屏 + - customAnimationCount 的使用方式 + - 幸运礼物 burst 口径 + +你的明确写入范围: + +- 可以修改: + - lib/services/audio/rtm_manager.dart + - lib/services/audio/rtc_manager.dart + - lib/modules/room/voice_room_page.dart + - lib/services/gift/gift_system_manager.dart + +- 可以新增: + - lib/modules/room_effect/application/room_effect_event_adapter.dart + - 或你认为更合适但职责清晰的 adapter/binding 文件 + +你的禁止写入范围: + +- 不要修改: + - lib/main.dart + - lib/modules/room_effect/presentation/** + - 线程 B 新增的 stage/layer 文件 + - 线程 A 的核心 engine 接口定义 + +你的产出要求: + +- 最终让旧入口尽量先统一走事件 +- 即便 engine/stage 还未最终接上,也要把触发点收成统一入口 +- 在最终回复中列出: + - 你改掉了哪些直接触发点 + - 每类事件映射成了什么 RoomEffectEvent + - 哪些旧逻辑你暂时保留未删 +``` + +--- + +### 线程 D Prompt + +用途: + +- A/B/C 合并后再启动,负责收尾、清理、挂载调整与回归 + +直接投喂: + +```text +在遵守“公共约定 prompt”的前提下,执行线程 D 的任务。 + +注意:这个线程默认在 A/B/C 已经完成并合并后再启动。你的职责是最终收尾,不是从零搭骨架。 + +你的主要目标: + +1. 把房间专属特效层尽量从根层收回房间内部,重点检查: + - lib/main.dart + - lib/modules/room/voice_room_page.dart + +2. 清理旧双轨逻辑,重点检查: + - lib/ui_kit/widgets/room/anim/room_entrance_widget.dart + - 旧的 RoomEntranceHelper 残留引用 + - 不再需要的直接 play/addMessage 入口 + +3. 统一房间生命周期清理: + - 退房 + - 最小化 + - 切房 + - 房间页面销毁 + +4. 回归以下重点问题: + - 房间退出后礼物图或飞向麦位残留 + - 房间飘屏跑到首页 + - 进场与全屏特效并发时队列紊乱 + - 幸运礼物逻辑回归 + +5. 你可以少量补充 metrics 或 debug log,但不要无限制新增临时调试代码。 + +你的明确写入范围: + +- 可以修改: + - lib/main.dart + - lib/modules/room/voice_room_page.dart + - lib/shared/data_sources/sources/local/floating_screen_manager.dart + - lib/shared/tools/sc_room_effect_scheduler.dart + - lib/ui_kit/widgets/room/anim/room_entrance_widget.dart + - 其它你确认属于“旧链路清理 / 最终挂载调整 / 生命周期回收”的文件 + +你的禁止写入范围: + +- 不要推翻线程 A 已经稳定的 engine 核心接口 +- 不要重命名线程 B 已经落好的 presentation 核心对象 +- 不要重新发明第二套 adapter 或第二套 lane + +你的产出要求: + +- 最终让新链路成为主链路 +- 旧链路要么删除,要么明确降级成只读残留且不再实际生效 +- 在最终回复中列出: + - 你清理了哪些旧链路 + - 你改了哪些根层/房间层挂载 + - 你验证了哪些关键回归项 + - 你认为还剩哪些适合下一轮继续处理 +``` + +--- + +### 推荐投喂顺序 + +建议你后面实际开线程时,按这个顺序喂: + +1. 给 A、B、C 同时发送“公共约定 prompt” +2. 再分别发送各自的线程 prompt +3. 等 A、B、C 产出后完成合并 +4. 最后再给 D 发送“公共约定 prompt + 线程 D prompt” + +### 推荐合并检查点 + +在真正合并 A/B/C 前,建议你人工确认一次这几个点: + +- A 的对象命名和 B/C 预期一致 +- B 的 stage/layer 命名没有另起炉灶 +- C 没有继续保留太多直接 `play()` 或 `addMessage()` 的新入口 +- 三边没有同时改坏 `voice_room_page.dart / rtm_manager.dart / rtc_manager.dart` + +--- + +## 最终建议 + +最终建议仍然是: + +- **先重构语言房特效系统** +- **不先重构整套语言房** +- **先统一入口、统一引擎、统一生命周期** +- **尽量保留当前礼物业务逻辑与视觉** + +这样做的收益最大,风险也最可控。 + +如果后续按本文实施,建议第一步从“阶段 0 + 阶段 1”开始,即: + +- 先建事件模型和指标 +- 再把所有直接触发动画的入口统一改成投递事件 + +一旦这一步完成,后面每条特效链路都可以逐条平滑迁移,不需要再一次性大改整间房。 diff --git a/需求进度.md b/需求进度.md index e61fb45..cc94db6 100644 --- a/需求进度.md +++ b/需求进度.md @@ -3,9 +3,23 @@ ## 当前总目标 - 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。 +## 本轮语言房背景页框架(进行中) +- 已按 2026-04-22 当前最新需求先落第一版页面骨架:新增两个独立路由,分别用于“背景选择页”和“自定义背景上传页”;其中语音房底部菜单里的 `Background` 图标入口,现已从旧的房间主题页切到新的背景选择页。 +- 背景选择页已按“接近项目商店页”的结构先搭好导航和内容骨架:顶部为返回键、`官方 / 我的` 两个 tab 和右侧 `自定义` 文本按钮;点击右侧 `自定义` 会跳转到新的上传页。当前页内已先补上双列背景卡片栅格、选中高亮和“使用中”状态角标,便于后续继续接后台数据。 +- 自定义背景上传页也已完成首版 UI 骨架:顶部为返回键和纯文本标题 `自定义背景图`,中间包含上传框、提示文案、示例区和底部 `Save` 按钮。当前已先接上本地相册选图和回传路径,保存后会把刚选的本地图先带回背景选择页,并插入到 `我的` tab 里做本地预览,先把交互骨架跑通。 +- 已按 2026-04-22 最新页面微调继续收口交互细节:背景选择页底部新增了和上传页 `Save` 同风格的悬浮 `add` 按钮,但当前只会在 `Mine` tab 下显示,点击效果与右上角 `Custom` 一致,都会进入上传页;同时 `Official / Mine` tab 的点击波浪反馈已去掉,避免和目标稿风格不一致。 +- 已继续统一两页正文区底色:当前背景选择页和背景上传页除顶部导航栏外的正文区域,都固定使用 `#0F0F0F`,不再透出全页背景图;顶部导航区域仍保留现有视觉,以便后续继续微调头部样式。 +- 当前这一步仍是“框架版”,还没有正式接入背景后台列表、我的背景接口、上传审核接口和房间背景实际应用逻辑;下一步会在现有骨架上继续把真实数据源、选中态持久化和房内背景切换接进来。 + ## 本轮靓号动效替换(已完成) - 已按 2026-04-21 最新靓号动效需求,把桌面“靓号动效”里的 `room_id / room_id_custom / user_id / user_id_4` 四套 `SVGA` 素材导入 Flutter 工程,并新增统一的靓号 ID 展示组件;当前会按 `ownSpecialId != null && expiredTime > now` 判定是否仍为有效靓号,过期靓号会自动回退到原始账号展示。 -- 已完成 4 个指定场景的替换:`room_id` 现用于房间详情里的房间号,`room_id_custom` 现用于房间详情里的房主 ID,`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;对应文案在命中靓号时会展示靓号号码,未命中时保持原始 `ID:` 文本样式。 +- 已完成 4 个指定场景的首轮替换接线:`room_id` 现用于房间详情里的房间号,`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;`room_id_custom` 也已同步导入工程并完成资源校验,后续又按最新规则把房内二级页的“个人 ID”统一收口到小号 `user_id.svga`。命中靓号时当前展示为“`SVGA` 徽章 + 靓号文本”同一行,未命中时保持原始 `ID:` 文本样式。 +- 已按 2026-04-21 最新视觉反馈继续微调靓号展示:个人主页的靓号行已改回原来的左对齐位置,不再额外居中;同时 `SVGA` 徽章与靓号文本的间距已整体收窄,并已再次核对 `sc_images/general/room_id_custom.svga` 已实际导入工程,避免后续把素材缺失和样式问题混在一起。 +- 已按 2026-04-21 最新补充要求继续统一房内二级页的个人 ID 展示:`SVGA` 与靓号文本间距现已进一步收敛到 `0`;房间成员列表、黑名单、贡献榜、房间个人卡片、房间在线列表以及房间详情里的房主个人 ID,命中靓号时都会统一改成“小号 `user_id.svga + 靓号文本`”样式,普通账号则继续沿用原始 `ID:` 文本。 +- 已按 2026-04-22 当前 UI 对稿需求,临时把这套靓号展示判定整体反转:当前个人主页、`Me` 页、房间在线列表、房间个人卡片、贡献榜、成员列表、黑名单、房间详情里的房主 ID,以及房间详情里的 `room id`,都会在“非靓号”时先显示对应 `SVGA` 徽章,便于集中校对尺寸与排版;这一步仅用于临时验 UI,后续确认完会再切回正式的“只有靓号才替换”逻辑。 +- 已补上“靓号且 VIP 时 ID 数字显示炫彩渐变字”的组件能力预留:当前统一在靓号 ID 组件里支持渐变文字渲染,并新增 `shouldShowColoredSpecialIdText()` 判断;现阶段先复用 `useProps` 里的 `NOBLE_VIP` 做兼容判断,等后端正式 VIP 字段稳定后可直接切到独立接口,不用再逐页返工。 +- 已按 2026-04-22 当前 UI 验收需求,临时把炫彩字展示条件整体放开:现阶段这些靓号 ID 展示位无论是否命中靓号/VIP 判断,都会先直接显示炫彩渐变字,专门用于集中验收字号、渐变配色和与 `SVGA` 徽章的搭配;后续确认完视觉稿后,再统一收回到正式条件判断。 +- 已于 2026-04-22 UI 验收完成后收回到正式逻辑:当前只有命中靓号时才会用 `SVGA` 替换原来的 `ID` 标识;而具体的账号数字或靓号数字,只有命中 VIP 条件时才会显示炫彩渐变文本。现阶段 VIP 判断先复用 `useProps` 里的 `NOBLE_VIP`,后端独立字段就绪后可继续无痛切换。 ## 本轮房间动效优化(进行中) - 已按 2026-04-21 当前房间卡顿优化方案先落第一步“统一调度层”:新增房间特效调度器,在全屏 `VAP/SVGA` 高成本特效播放或排队期间,暂缓低优先级的房间进场动画、全局飘屏和座位飞屏入队,待高成本特效清空后按小间隔续播,先减少多条动画链路同一时刻抢主线程的情况。 @@ -126,6 +140,8 @@ - 已按最新联调反馈回撤 BAISHUN 页面的横屏适配:当前后续不会接横屏游戏,因此已去掉游戏页按 `orientation` 切换系统方向的逻辑,并撤回 iPhone 端新增的横屏声明,只保留独立路由承载 BAISHUN 页面这一项 Flutter 容器修正。同时已继续增强临时调试,在 H5 侧补充 `pointerdown / touchstart / mousedown / click` 事件回传,用于下一轮真机直接确认“点击是否真的进到了 H5 DOM”;这些输入事件日志依旧只用于本轮排障,后续需要删除。 - 已按当前收尾需求移除 BAISHUN 临时调试面板:`BS DEBUG` 组件和相关调试入口现已从游戏页删除,不再作为正式功能保留;同时游戏页展示高度已恢复为接近半屏的底部弹出样式,回到最初设定的房内游戏视觉形态。 - 已继续微调 BAISHUN 游戏页高度:由于真实 H5 内容本身就是接近半屏,且顶部还需要预留拖拽条和标题栏空间,当前底部游戏页容器高度已从上一版的 `0.56` 屏高上调到 `0.62`,避免视觉上看起来比预期半屏更矮。 +- 已按 2026-04-22 最新后端协议把语言房游戏数据层切到 `provider` 通用接口:当前会先拉 `/app/game/providers`,再按每个 `provider` 分别请求房间游戏列表,并在点击启动时改走 `/app/game/providers/{provider}/launch`、退出时改走 `/app/game/providers/{provider}/close`;列表项里的 `provider / gameType / launchParams` 也已补进 Flutter 模型,并在启动请求里把 `launchParams` 原样透传回后端。现阶段二级游戏页仍只接 BAISHUN bridge,其余厂商先保留“未接线”分流提示,避免影响现有百顺逻辑。 +- 已继续补上新合作商的前端二级游戏壳子:当前语言房游戏列表点击后,除 BAISHUN 继续走原有 `BaishunGamePage` 外,命中 `provider/gameType=LEADER` 时会进入新增的 `LeaderGamePage`;该页已先具备独立 WebView 容器、底部弹窗样式、`pay / closeGame / loadComplete` 三个 H5 -> Flutter bridge 动作监听,以及充值返回后调用 `updateCoin()` 的 Flutter -> H5 回调。现阶段它仍属于前端预埋壳子,待后端 Leader 启动地址和真实 H5 ready 后再继续补联调细节。 - 创建并持续维护进度跟踪文件。 - 已继续排查语言房 gift 动画链路:确认送礼后会同时走本地房间消息、滚屏礼物条和大额礼物全局飘屏三条路径,并修复动画管理器在控制器尚未绑定完成时提前消费队列导致后续动画不再播放的问题。 - 已定位并修复语言房礼物飘屏资源引用错误:代码里误写 `sc_icon_gift_flosc_bg` / `sc_icon_luck_gift_flosc_*`,实际资源文件名为 `float`,导致点击送礼后飘屏背景图加载失败,相关动画无法正常显示。 @@ -221,7 +237,11 @@ - `lib/services/gift/gift_animation_manager.dart` - `lib/services/audio/rtc_manager.dart` - `lib/modules/room/voice_room_page.dart` +- `lib/modules/room/voice_room_route.dart` +- `lib/modules/room/background/room_background_select_page.dart` +- `lib/modules/room/background/room_background_upload_page.dart` - `lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart` +- `lib/ui_kit/widgets/room/room_menu_dialog.dart` - `lib/ui_kit/widgets/room/anim/room_entrance_screen.dart` - `lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart` - `lib/modules/room/edit/room_edit_page.dart` @@ -249,6 +269,11 @@ - `lib/modules/user/me_page2.dart` - `lib/modules/user/profile/person_detail_page.dart` - `lib/shared/business_logic/models/res/login_res.dart` +- `lib/app_localizations.dart` +- `assets/l10n/intl_en.json` +- `assets/l10n/intl_ar.json` +- `assets/l10n/intl_bn.json` +- `assets/l10n/intl_tr.json` - `lib/ui_kit/widgets/id/sc_special_id_badge.dart` - `lib/ui_kit/widgets/room/room_user_info_card.dart` - `lib/modules/home/popular/party/sc_home_party_page.dart` @@ -442,6 +467,7 @@ - `burst` 头像继续按最新口头要求单独微调:向下 `10` 个设计单位、向左 `2` 个设计单位,其余金额和倍数位置不动 - `burst` 头像又继续单独向下补移 `8` 个设计单位,当前累计为向下 `18`、向左 `2` - 飞向麦位动画补了统一的“中心图空闲同步”逻辑:目标坐标重试失败放弃、队列被清空、上一段飞行结束后,都会把中心静态礼物图切到队列头或直接清空,避免残留卡死在屏幕中央 +- 房间游戏 Leader 二级页已补齐新 provider 化接口下的 `launchConfig` 注入链路:前端现在会保留后端原始 `launchConfig` 字段,并在 WebView 内注入 `leaderLaunchConfig/getConfig()` 供 H5 读取,便于后端完成后直接联调 ## 下一步要做什么 - 将新的 `AAB` 上传到 Play Console,并在首发流程中继续使用 Google 管理的 app signing key。