Compare commits

...

8 Commits

Author SHA1 Message Date
roxy
36ba0f3573 1.2.6 2026-04-30 10:11:30 +08:00
roxy
fd445ccdaa vip接口 以及房间兜底 2026-04-29 20:37:03 +08:00
roxy
9f4df82c42 vip 接口 游戏及 重新安装等问题 2026-04-29 19:23:51 +08:00
roxy
7d12417116 bug fix 2026-04-29 19:04:14 +08:00
roxy
62d6405aa0 bgm播放器 进厨房bug fix 2026-04-29 18:28:39 +08:00
roxy
b0d68d2756 bgm播放器 第二版 2026-04-29 17:32:00 +08:00
roxy
ddb378f5b4 音乐播放 2026-04-29 14:52:09 +08:00
roxy
4de26d7663 task 2026-04-29 14:09:34 +08:00
89 changed files with 9185 additions and 1530 deletions

View File

@ -3,14 +3,19 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"
tools:node="remove" />
@ -33,11 +38,15 @@
</intent>
<package android:name="com.snapchat.android" />
</queries>
<application
android:name="${applicationName}"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true">
<application
android:name="${applicationName}"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include
domain="sharedpref"
path="." />
</full-backup-content>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include
domain="sharedpref"
path="." />
</cloud-backup>
<device-transfer>
<include
domain="sharedpref"
path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -42,6 +42,7 @@
"game": "لعبة",
"invite": "دعوة",
"claim": "استلام",
"claimed": "تم الاستلام",
"recent": "الأحدث",
"popularEvents": "الأحداث الرائجة",
"exitGameMode": "الخروج من وضع اللعبة",
@ -534,7 +535,13 @@
"deleteAccount2": "حذف الحساب ({1}ث)",
"areYouSureYouWantToDeleteYourAccount": "هل أنت متأكد أنك تريد حذف حسابك؟",
"enterRoomConfirmTips": "هل أنت متأكد أنك تريد دخول الغرفة؟",
"dailyTasks": "المهام اليومية",
"dailyTasks": "المهام اليومية",
"exclusiveForNewcomers": "حصري للقادمين الجدد",
"resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل",
"limitedToOneTime": "لمرة واحدة فقط",
"useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة",
"rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح",
"rewardClaimFailed": "فشل استلام المكافأة",
"nickName": "الاسم المستعار",
"giftSpecialEffects": "تأثيرات الهدايا الخاصة",
"country": "دولة",
@ -650,6 +657,18 @@
"lightMode": "الوضع الفاتح",
"systemDefault": "النظام الافتراضي",
"pleaseGetOnTheMicFirst": "يرجى الصعود إلى الميكروفون أولاً.",
"roomMusicAddMusic": "إضافة موسيقى",
"roomMusicSelectAll": "تحديد الكل",
"roomMusicAddedCount": "تمت إضافة {1} أغنية",
"roomMusicEmpty": "لا توجد موسيقى",
"roomMusicScanFromPhone": "المسح من الهاتف",
"roomMusicTotalSongs": "الإجمالي: {1} أغنية",
"roomMusicNotFound": "لم يتم العثور على موسيقى",
"roomMusicDeleteConfirm": "هل تريد حذف هذه الموسيقى؟",
"roomMusicDeleted": "تم حذف الموسيقى",
"roomMusicListMode": "تشغيل بالترتيب",
"roomMusicShuffleMode": "تشغيل عشوائي",
"roomMusicSingleMode": "تكرار أغنية واحدة",
"welcomeMessage": "مرحبًا بك في تطبيقنا، {name}!",
"operationFail": "فشلت العملية.",
"enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",

View File

@ -101,6 +101,7 @@
"skipCountdown": "{1}সে স্কিপ",
"coins4": "কয়েন",
"claim": "গ্রহণ",
"claimed": "গ্রহণ করা হয়েছে",
"complete": "সম্পূর্ণ",
"shareTo": "শেয়ার করুন",
"copyLink": "লিঙ্ক কপি করুন",
@ -466,6 +467,12 @@
"floatingAnimationInGlobal": "গ্লোবালে ভাসমান অ্যানিমেশন",
"entryVehicleAnimation2": "VIP4 বা তার বেশি অধিকারধারী ব্যবহারকারীরা এই ফিচার ব্যবহার করে গাড়ির অ্যানিমেশন বন্ধ করতে পারেন।",
"dailyTasks": "দৈনন্দিন টাস্ক",
"exclusiveForNewcomers": "নতুনদের জন্য বিশেষ",
"resetsDailyAtMidnight": "প্রতিদিন মধ্যরাতে রিসেট হয়",
"limitedToOneTime": "শুধু একবারের জন্য",
"useMicrophoneForOneMin": "১ মিনিট মাইক্রোফোন ব্যবহার করুন",
"rewardClaimedSuccessfully": "পুরস্কার সফলভাবে গ্রহণ করা হয়েছে",
"rewardClaimFailed": "পুরস্কার গ্রহণ ব্যর্থ হয়েছে",
"enterRoomConfirmTips": "আপনি কি রুমে যেতে চান?",
"followSucc": "সফলভাবে ফলো করা হয়েছে",
"goldListort": "সোনা তালিকা",
@ -655,5 +662,17 @@
"lightMode": "লাইট মোড",
"systemDefault": "সিস্টেম ডিফল্ট",
"pleaseGetOnTheMicFirst": "অনুগ্রহ করে আগে মাইকে উঠুন।",
"roomMusicAddMusic": "মিউজিক যোগ করুন",
"roomMusicSelectAll": "সব নির্বাচন করুন",
"roomMusicAddedCount": "{1}টি গান যোগ হয়েছে",
"roomMusicEmpty": "কোনো মিউজিক নেই",
"roomMusicScanFromPhone": "ফোন থেকে স্ক্যান করুন",
"roomMusicTotalSongs": "মোট: {1}টি গান",
"roomMusicNotFound": "কোনো মিউজিক পাওয়া যায়নি",
"roomMusicDeleteConfirm": "এই মিউজিক মুছে ফেলবেন?",
"roomMusicDeleted": "মিউজিক মুছে ফেলা হয়েছে",
"roomMusicListMode": "ক্রম অনুসারে চালান",
"roomMusicShuffleMode": "শাফল চালান",
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
"duration2": "সময়:{1}"
}

View File

@ -437,6 +437,12 @@
"floatingAnimationInGlobal": "Floating animation in global",
"entryVehicleAnimation2": "Users with VIP4 or higher privileges can use this function to disable vehicle animations.",
"dailyTasks": "Daily Tasks",
"exclusiveForNewcomers": "Exclusive For Newcomers",
"resetsDailyAtMidnight": "Resets daily at midnight",
"limitedToOneTime": "Limited to one time",
"useMicrophoneForOneMin": "Use the microphone for 1 min",
"rewardClaimedSuccessfully": "Reward claimed successfully",
"rewardClaimFailed": "Reward claim failed",
"enterRoomConfirmTips": "Are you sure you want to enter the room?",
"followSucc": "Followed successfully",
"goldListort": "Gold List",
@ -528,6 +534,7 @@
"guest": "Guest",
"submit": "Submit",
"claim": "Claim",
"claimed": "Claimed",
"complete": "Complete",
"shareTo": "Share to",
"copyLink": "Copy Link",
@ -655,5 +662,17 @@
"lightMode": "Light Mode",
"systemDefault": "System Default",
"pleaseGetOnTheMicFirst": "Please get on the mic first.",
"roomMusicAddMusic": "Add Music",
"roomMusicSelectAll": "Select all",
"roomMusicAddedCount": "Added {1} songs",
"roomMusicEmpty": "No music",
"roomMusicScanFromPhone": "Scan from phone",
"roomMusicTotalSongs": "Total: {1} songs",
"roomMusicNotFound": "No music found",
"roomMusicDeleteConfirm": "Delete this music?",
"roomMusicDeleted": "Music deleted",
"roomMusicListMode": "Play in order",
"roomMusicShuffleMode": "Shuffle play",
"roomMusicSingleMode": "Single loop",
"duration2": "Duration:{1}"
}

View File

@ -435,7 +435,13 @@
"entryVehicleAnimation": "Giriş Aracı Animasyonu",
"floatingAnimationInGlobal": "Genel Ekranda Yüzen Animasyon",
"entryVehicleAnimation2": "VIP4 veya daha yüksek haklara sahip kullanıcılar araç animasyonlarını devre dışı bırakmak için fonksiyonu kullanabilir.",
"dailyTasks": "Günlük Görevler",
"dailyTasks": "Günlük Görevler",
"exclusiveForNewcomers": "Yeni Gelenlere Özel",
"resetsDailyAtMidnight": "Her gün gece yarısı sıfırlanır",
"limitedToOneTime": "Yalnızca bir kez",
"useMicrophoneForOneMin": "Mikrofonu 1 dakika kullan",
"rewardClaimedSuccessfully": "Ödül başarıyla alındı",
"rewardClaimFailed": "Ödül alınamadı",
"enterRoomConfirmTips": "Odaya girmek istediğinizden emin misiniz?",
"followSucc": "Başarıyla takip edildi",
"goldListort": "Altın Listesi",
@ -579,6 +585,7 @@
"bdLeader": "BD Lideri",
"picture": "Resim",
"claim": "Talep Et",
"claimed": "Alındı",
"taskNameRoomNewMember": "Yeni Oda Üyeleri",
"taskNameRoomOwnerSendRedPacket": "Oda Sahibi bir kırmızı paket gönderiyor",
"taskNameRoomOwnerSendGiftUser": "Oda Sahibi Hediye Gönderir",
@ -654,6 +661,18 @@
"darkMode": "Karanlık Mod",
"lightMode": "Açık Mod",
"systemDefault": "Sistem Varsayılanı",
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"duration2": "Süre:{1}"
}
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"roomMusicAddMusic": "Müzik Ekle",
"roomMusicSelectAll": "Tümünü seç",
"roomMusicAddedCount": "{1} şarkı eklendi",
"roomMusicEmpty": "Müzik yok",
"roomMusicScanFromPhone": "Telefondan tara",
"roomMusicTotalSongs": "Toplam: {1} şarkı",
"roomMusicNotFound": "Müzik bulunamadı",
"roomMusicDeleteConfirm": "Bu müziği silmek istiyor musunuz?",
"roomMusicDeleted": "Müzik silindi",
"roomMusicListMode": "Sırayla çal",
"roomMusicShuffleMode": "Karışık çal",
"roomMusicSingleMode": "Tek şarkı döngüsü",
"duration2": "Süre:{1}"
}

View File

@ -0,0 +1,548 @@
# Android 语言房音乐功能执行方案
整理时间2026-04-29
适用范围Android 单端,语言房内 MP3 背景音乐添加、管理与播放
参考文档:`/Users/nigger/Desktop/Android音乐添加流程.md`
## 1. 目标
在语言房内补齐一套可上线的 Android 音乐能力:
- 从房间操作面板进入音乐功能。
- 首次为空时引导扫描手机 MP3。
- 支持 Android 音频权限申请与本地 MP3 扫描。
- 按文件夹展示扫描结果,支持选择音乐添加到房间音乐列表。
- 支持已添加音乐的搜索、删除、排序、二次添加、取消已添加。
- 支持播放器:播放、暂停、停止、上一首、下一首、播放模式、音量、进度条拖动。
- 支持麦下仅自己听,麦上推流给房间其他用户听。
- 离房、切房、删除当前播放歌曲时正确停止并清理播放状态。
不纳入本次范围:
- iOS 权限和 iOS 媒体库适配。
- 在线音乐、歌词、专辑封面精修。
- 后台常驻播放通知栏。
- 服务端同步歌单。当前按用户本地持久化处理。
## 2. 项目现状
已有基础:
- `agora_rtc_engine: ^6.5.2` 已接入,实际锁定版本为 6.5.3。
- `lib/services/audio/rtc_manager.dart` 已有 `startAudioMixing(String localPath, int cycle)` 雏形。
- `rtc_manager.dart` 已监听 `onAudioMixingStateChanged`,但只维护了 `isMusicPlaying`
- `lib/shared/data_sources/models/sc_music_mode.dart` 已有基础音乐模型。
- `lib/shared/data_sources/sources/local/data_persistence.dart` 已有 `getUserRoomMusic()` / `setUserRoomMusic()`
- `sc_images/room/` 已有音乐相关图标:播放、暂停、上一首、下一首、音量、空态、播放模式、删除、添加、音乐入口等。
- 多语言中已有 `music``myMusic``localMusic``add``delete``search``edit``confirm``cancel` 等基础文案。
- `local_packages/on_audio_query-2.9.0``local_packages/on_audio_query_android-1.1.0` 已存在,可用于 Android MediaStore 音频扫描。
需要补齐:
- `pubspec.yaml` 尚未声明 `on_audio_query` 依赖。
- AndroidManifest 缺少 Android 13+ 的 `READ_MEDIA_AUDIO`
- `SCMusicMode` 字段不足,缺少稳定 id、content uri、文件夹路径、排序、添加时间等。
- 音乐入口在 `lib/ui_kit/widgets/room/room_menu_dialog.dart` 中曾经存在,但当前被注释。
- 没有独立音乐状态管理器,播放器状态不应继续堆进已经很长的 `RtcProvider`
- Agora 音频混流只封装了开始播放缺少暂停、恢复、停止、音量、进度、seek、播放完成切歌、麦上/麦下路由切换。
## 3. 总体技术方案
采用“扫描服务 + 歌单状态管理 + Agora 播放适配 + UI 页面”的拆分方式。
```mermaid
flowchart LR
A["房间菜单 音乐入口"] --> B["RoomMusicPage"]
B --> C["RoomMusicManager"]
C --> D["RoomMusicRepository"]
D --> E["DataPersistence"]
C --> F["LocalMusicScanner"]
F --> G["on_audio_query / MediaStore"]
C --> H["RoomMusicPlayer"]
H --> I["RtcProvider / Agora RtcEngine"]
```
推荐新增目录:
- `lib/services/music/`
- `lib/modules/room/music/`
- `lib/ui_kit/widgets/room/music/`
推荐新增核心文件:
- `lib/shared/data_sources/models/sc_music_mode.dart`:扩展现有模型。
- `lib/shared/data_sources/models/sc_music_folder_mode.dart`:新增本地扫描文件夹模型。
- `lib/services/music/local_music_scanner.dart`Android 本地 MP3 扫描。
- `lib/services/music/room_music_repository.dart`:已添加歌单本地持久化。
- `lib/services/music/room_music_manager.dart`:歌单、选择、播放状态总控。
- `lib/modules/room/music/room_music_page.dart`:音乐列表页。
- `lib/modules/room/music/room_music_folder_page.dart`:扫描后的文件夹列表页。
- `lib/modules/room/music/room_music_select_page.dart`:文件夹内音乐选择页。
- `lib/ui_kit/widgets/room/music/room_music_player_bar.dart`:播放器控制条。
- `lib/ui_kit/widgets/room/music/room_music_list_item.dart`:音乐列表项。
- `lib/ui_kit/widgets/room/music/room_music_empty_state.dart`:空状态。
## 4. 依赖与权限
### 4.1 pubspec
`pubspec.yaml` 增加:
```yaml
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
```
如果代码中直接使用 `package:path/path.dart` 做文件夹路径处理,建议把 `path` 显式加入依赖,避免依赖传递包:
```yaml
path: ^1.9.0
```
### 4.2 AndroidManifest
现有 Manifest 已有:
- `READ_EXTERNAL_STORAGE`
- `WRITE_EXTERNAL_STORAGE`
需要补充 Android 13+ 音频权限,并建议限制旧权限版本:
```xml
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
```
### 4.3 权限工具
`lib/shared/tools/sc_permission_utils.dart` 增加 `checkAudioPermission()`
- Android 13+:请求 `Permission.audio`
- Android 12 及以下:请求 `Permission.storage`
- 需要使用项目已有 `device_info_plus` 判断 SDK 版本。
- 权限拒绝时不进入扫描结果页,只保留在当前流程;后续可补“去设置开启”引导。
## 5. 数据模型
### 5.1 音乐项
扩展 `SCMusicMode`,兼容旧字段读取:
```dart
class SCMusicMode {
final String id; // 优先用 MediaStore id兜底用 localPath hash
final String title;
final String artist;
final int durationMs;
final String localPath; // SongModel.data
final String contentUri; // SongModel.uriAgora Android 推荐优先使用
final String folderPath;
final String fileName;
final int size;
final int sortIndex;
final int addedAt;
}
```
关键点:
- 扫描只保留 `fileExtension == mp3` 或文件名 `.mp3` 的音频。
- 播放时优先使用 `contentUri`,为空再用 `localPath`
- 持久化仍走 `DataPersistence.setUserRoomMusic(jsonEncode(list))`
- 读取旧数据时要兼容当前模型里的 `title``artist``duration``localPath`
### 5.2 文件夹模型
新增 `SCMusicFolderMode`
```dart
class SCMusicFolderMode {
final String id;
final String name;
final String path;
final int musicCount;
final int addedCount;
final List<SCMusicMode> songs;
}
```
文件夹统计规则:
- 使用 `p.dirname(song.localPath)` 作为文件夹路径。
- 单个文件夹只统计该文件夹当前层级直接包含的 MP3。
- 子文件夹内 MP3 归入子文件夹,不累计到父文件夹。
- 文件夹列表按名称或路径稳定排序。
## 6. 播放器方案
### 6.1 播放方式
统一使用 Agora `startAudioMixing`,原因是麦上需要把音乐推给远端。
麦下播放:
```dart
engine.startAudioMixing(
filePath: playPath,
loopback: true,
cycle: 1,
startPos: currentPositionMs,
);
```
麦上播放:
```dart
engine.startAudioMixing(
filePath: playPath,
loopback: false,
cycle: 1,
startPos: currentPositionMs,
);
```
这里的 `loopback` 是关键:
- `true`:只本地播放,只有自己听到。
- `false`:本地和远端都能听到。
### 6.2 麦上/麦下同步
当用户播放中上麦或下麦时,需要切换播放路由:
1. 记录当前播放位置:`getAudioMixingCurrentPosition()`
2. `stopAudioMixing()`
3. 根据 `RtcProvider.isOnMai()` 重新 `startAudioMixing(loopback: !isOnMai, startPos: position)`
4. 恢复播放态和进度轮询。
接入点建议:
- 在 `RtcProvider` 上麦成功、下麦成功、角色切换成功后调用 `RoomMusicManager.syncPlaybackRouting(rtcProvider)`
- 离房清理流程调用 `RoomMusicManager.stopAndReset()`.
### 6.3 控制能力
`RoomMusicManager` 需要封装:
- `play(SCMusicMode item)`
- `pause()`
- `resume()`
- `stop()`
- `previous()`
- `next({bool fromAutoComplete = false})`
- `seek(Duration position)`
- `setVolume(int percent)`
- `switchPlayMode()`
- `deleteMusic(SCMusicMode item)`
- `reorder(int oldIndex, int newIndex)`
Agora 对应 API
- `startAudioMixing`
- `pauseAudioMixing`
- `resumeAudioMixing`
- `stopAudioMixing`
- `adjustAudioMixingVolume`
- `adjustAudioMixingPlayoutVolume`
- `adjustAudioMixingPublishVolume`
- `getAudioMixingDuration`
- `getAudioMixingCurrentPosition`
- `setAudioMixingPosition`
音量策略:
- UI 展示 0-100%。
- 默认同时调用 `adjustAudioMixingPlayoutVolume(volume)``adjustAudioMixingPublishVolume(volume)`
- 如果后续产品区分“自己听到的音量”和“房间听到的音量”,再拆成两个滑杆。
### 6.4 播放模式
定义:
```dart
enum RoomMusicPlayMode {
list,
shuffle,
single,
}
```
规则:
- 列表顺序播放:播放完成后按当前排序下一首,末尾回到第一首或停止,建议按原型做循环到第一首。
- 随机播放:播放完成后随机选择非当前歌曲;只有一首时播放自身。
- 单曲循环:播放完成后继续当前歌曲。
切换 Toast
- `列表顺序播放`
- `随机播放`
- `单曲循环播放`
### 6.5 播放完成
监听 `onAudioMixingStateChanged`
- `audioMixingStatePlaying`:更新为播放中。
- `audioMixingStatePaused`:更新为暂停。
- `audioMixingStateStopped` + `audioMixingReasonAllLoopsCompleted`:按播放模式自动下一首。
- `audioMixingStateStopped` + `audioMixingReasonStoppedByUser`:停止态。
- `audioMixingStateFailed`Toast 提示并停止当前播放,常见原因是文件被删除、路径无权限、格式不支持。
## 7. 页面与交互落地
### 7.1 房间操作面板入口
修改 `lib/ui_kit/widgets/room/room_menu_dialog.dart`
- 恢复 `音乐` 入口。
- 点击后关闭菜单,打开 `RoomMusicPage`
- 建议房主/管理员/普通用户都能看到入口;是否允许播放由房间配置 `allowMusic` 决定。如果产品要求强制限制,则判断 `currenRoom.roomProfile.roomSetting.allowMusic`
### 7.2 音乐列表页
`RoomMusicPage` 状态:
- 空列表:展示空态图标、`没有音乐``从手机扫描添加`
- 有列表:顶部 `添加`、搜索入口、编辑入口、总数、歌曲列表。
- 播放中:当前播放项高亮,底部显示播放器条。
- 编辑态:支持拖拽排序;也可保留左滑删除。
列表能力:
- 搜索按 `title` / `fileName` 包含匹配。
- 左滑删除,弹窗确认 `确定删除音乐?`
- 删除当前播放歌曲时,先 `stop()`,再删除并持久化。
- 拖拽排序后立即更新 `sortIndex` 并持久化。
### 7.3 添加流程
流程:
```mermaid
flowchart TD
A["点击 添加/从手机扫描添加"] --> B["检查音频权限"]
B -->|"拒绝"| C["停留当前页"]
B -->|"允许"| D["扫描 MediaStore MP3"]
D -->|"无结果"| E["Toast 未找到音乐"]
D -->|"有结果"| F["文件夹列表页"]
F --> G["选择文件夹"]
G --> H["音乐选择页"]
H --> I["勾选/取消勾选"]
I --> J["点击 添加"]
J --> K["更新已添加列表并返回音乐列表"]
```
扫描规则:
- 每次进入添加流程都重新调用 `querySongs()`
- 过滤 MP3。
- 过滤 `duration <= 0` 的异常项。
- 文件不存在或 `localPath` 为空的项不进入结果。
- 对同一个 MediaStore id 或同一路径去重。
选择规则:
- 已添加歌曲显示灰色勾选。
- 本次新选歌曲显示高亮勾选。
- 用户点击灰色勾选可取消已添加,提交后从列表移除。
- 只有存在“新增歌曲”时,添加按钮高亮;如果只有取消已添加,建议按钮文案仍为 `添加` 但可点击,否则无法完成取消动作。这里与原型“无新增则置灰”有轻微冲突,建议和产品确认;若严格按原型,则取消已添加后也需要允许提交,否则规则无法闭环。
## 8. 具体实施步骤
### 阶段 1基础依赖与数据层
预计 4-6 小时。
1. 修改 `pubspec.yaml`,接入 `on_audio_query`
2. 修改 `android/app/src/main/AndroidManifest.xml`,补 `READ_MEDIA_AUDIO`
3. 扩展 `SCPermissionUtils.checkAudioPermission()`
4. 扩展 `SCMusicMode`,新增 `SCMusicFolderMode`
5. 新增 `RoomMusicRepository`,封装读取、保存、去重、排序。
6. 运行 `flutter pub get` 和基础静态检查。
### 阶段 2扫描与添加流程
预计 6-8 小时。
1. 新增 `LocalMusicScanner`
2. 使用 `OnAudioQuery.checkAndRequest()` 或项目权限工具 + `querySongs()` 扫描。
3. 实现 MP3 过滤、文件夹分组、已添加数量统计。
4. 实现文件夹列表页。
5. 实现音乐选择页、全选、新增勾选、已添加灰色勾选、取消已添加。
6. 添加成功后更新本地持久化并返回音乐列表。
### 阶段 3音乐列表与 CRUD
预计 6-8 小时。
1. 实现 `RoomMusicPage` 空态、有数据态。
2. 接入搜索。
3. 接入左滑删除和二次确认。
4. 删除当前播放歌曲时停止播放。
5. 接入拖拽排序并持久化。
6. 接入口入口菜单。
### 阶段 4播放器与 Agora 混流
预计 8-12 小时。
1. 新增 `RoomMusicManager extends ChangeNotifier`
2. 在 `main.dart``MultiProvider` 中注册。
3. 封装播放、暂停、恢复、停止、上下首。
4. 封装进度轮询,间隔建议 500-1000ms。
5. 封装 `seek` 与音量调节。
6. 实现三种播放模式。
7. 处理 `onAudioMixingStateChanged` 自动切歌与失败兜底。
8. 在上麦/下麦/离房流程接入播放路由同步和清理。
### 阶段 5UI 细节与真机验证
预计 6-10 小时。
1. 调整播放器底部高度,避免遮挡列表。
2. 接入播放中音乐 icon 旋转动画。
3. 补充多语言 key没有音乐、从手机扫描添加、未找到音乐、确定删除音乐、音乐已删除、总计歌曲、播放模式 Toast 等。
4. Android 12、Android 13+ 真机验证权限。
5. 真机验证麦上/麦下播放、闭麦不影响音乐输出。
6. 回归语言房退出、切房、最小化、重新进入房间。
## 9. 建议工时
按 Android 单端、GPT-5.5 + Codex 协作速度估算:
| 模块 | 预计耗时 |
|---|---:|
| 依赖、权限、数据模型、持久化 | 4-6 小时 |
| MP3 扫描、文件夹分组、添加选择 | 6-8 小时 |
| 已添加列表、搜索、删除、排序 | 6-8 小时 |
| Agora 播放器、进度、音量、模式、上下首 | 8-12 小时 |
| 房间入口、上麦/下麦同步、离房清理 | 4-6 小时 |
| Android 真机测试与修复 | 6-10 小时 |
总计建议预留34-50 小时。
如果先做 MVP
- 扫描 MP3
- 添加到列表
- 播放/暂停/停止
- 上下首
- 音量
- 进度条
- 删除
- 麦下本地播放、麦上推流播放
预计22-32 小时。
完整对齐原型并做稳定上线版本,建议按 40 小时左右排期。
## 10. 风险与处理
### 10.1 Android 10+ 路径访问
Agora 文档建议 Android 播放本地文件优先使用 `content://` URI。`on_audio_query``SongModel.uri` 可作为 `contentUri` 保存。
处理:
- 播放路径优先级:`contentUri` > `localPath`
- 如果 `audioMixingReasonCanNotOpen`,提示文件无法播放,并可从列表移除失效文件。
### 10.2 添加按钮规则冲突
原型写到“无新增音乐时添加按钮置灰”,同时又写到“点击置灰已添加可取消,点击添加后移除”。如果只取消已添加但没有新增,按钮置灰会导致无法提交移除。
建议:
- 只要本次选择结果和原已添加状态有差异,按钮即可点击。
- 如果产品坚持按钮必须叫 `添加`,仍然允许提交取消动作。
### 10.3 播放状态跨麦位变化
用户麦下播放时是 `loopback: true`,上麦后需要切到 `loopback: false`。Agora 没有直接切换 loopback 的接口,需要 stop/start。
处理:
- 切换前读取进度。
- 用 `startPos` 恢复。
- 加 500ms 节流,避免连续上麦状态变化触发频繁 start。
### 10.4 当前播放歌曲被删除
处理:
- 删除前判断 `current?.id == target.id`
- 是当前歌曲则先 `stopAudioMixing()`
- 清空当前播放项和进度。
- 再持久化删除。
### 10.5 房间生命周期
处理:
- 离房、RTC disconnected 清理、App termination 释放前调用音乐停止。
- 切房时不保留上一房间播放状态。
- 最小化播放器仅影响 UI不影响播放。
## 11. 验收清单
权限与扫描:
- Android 13+ 首次添加触发音频权限弹窗。
- Android 12 及以下可正常读取本地音乐。
- 拒绝权限后不会进入扫描结果页。
- 无 MP3 时 Toast `未找到音乐`
- 扫描结果只包含 MP3。
- 文件夹数量符合“只统计当前层级直接音乐”的规则。
添加与列表:
- 空列表展示空态。
- 添加后返回音乐列表并展示总数。
- 二次添加能展示已添加数量。
- 已添加歌曲灰色勾选,新选择歌曲高亮勾选。
- 取消已添加并提交后,音乐列表移除对应歌曲。
- 搜索能按名称过滤。
- 左滑删除有确认弹窗。
- 删除成功 Toast `音乐已删除`
- 拖拽排序后重新进入页面仍保持顺序。
播放:
- 点击歌曲开始播放。
- 播放/暂停/恢复正常。
- 上一首/下一首正常。
- 进度条自动推进。
- 拖动进度条能 seek。
- 音量调节生效。
- 列表顺序、随机、单曲循环三种模式正常。
- 播放完成后按当前模式切歌。
- 删除当前播放歌曲后立即停止。
房间场景:
- 麦下播放只有自己听到。
- 播放中上麦后,其他用户能听到音乐。
- 播放中下麦后,其他用户不再听到音乐,本地继续播放。
- 闭麦不影响音乐推流。
- 离房后音乐停止。
- 切房后不会继续上一房间音乐。
## 12. 推荐落地顺序
优先顺序:
1. 数据模型、持久化、权限、扫描。
2. 文件夹页和选择页。
3. 音乐列表页和入口。
4. 基础播放、暂停、停止。
5. 上下首、进度、音量、模式。
6. 麦上/麦下同步。
7. 删除、排序、搜索和细节回归。
这样可以尽早在真机上验证最不确定的两件事Android MediaStore 扫描结果,以及 Agora 使用 `contentUri` 播放本地 MP3 的兼容性。

View File

@ -209,6 +209,9 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- on_audio_query_ios (0.0.1):
- Flutter
- SwiftyBeaver
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@ -231,6 +234,7 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyBeaver (1.9.5)
- tancent_vap (0.0.1):
- Flutter
- tencent_cloud_chat_sdk (8.0.0):
@ -269,6 +273,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- on_audio_query_ios (from `.symlinks/plugins/on_audio_query_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@ -314,6 +319,7 @@ SPEC REPOS:
- RecaptchaInterop
- SDWebImage
- SDWebImageWebPCoder
- SwiftyBeaver
- TOCropViewController
- TXIMSDK_Plus_iOS_XCFramework
@ -348,6 +354,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
on_audio_query_ios:
:path: ".symlinks/plugins/on_audio_query_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@ -414,6 +422,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@ -424,6 +433,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
tancent_vap: fa8ad93814a9f950514a7074c662d2c937084c68
tencent_cloud_chat_sdk: 55e5fffe20f6b7937a26a674ccccb639563a9790
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863

View File

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

View File

@ -1,13 +1,91 @@
import Flutter
import UIKit
import Flutter
import Security
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
registerDurableAuthStorageChannel()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func registerDurableAuthStorageChannel() {
guard let controller = window?.rootViewController as? FlutterViewController else {
return
}
let channel = FlutterMethodChannel(
name: "com.org.yumiparty/durable_auth_storage",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
guard
let self,
let args = call.arguments as? [String: Any],
let key = args["key"] as? String
else {
result(FlutterError(code: "bad_args", message: "Missing key", details: nil))
return
}
switch call.method {
case "write":
guard let value = args["value"] as? String else {
result(FlutterError(code: "bad_args", message: "Missing value", details: nil))
return
}
self.writeKeychainValue(value, key: key)
result(nil)
case "read":
result(self.readKeychainValue(key: key))
case "delete":
self.deleteKeychainValue(key: key)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func keychainQuery(key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.org.yumiparty.durable_auth_storage",
kSecAttrAccount as String: key
]
}
private func writeKeychainValue(_ value: String, key: String) {
deleteKeychainValue(key: key)
guard let data = value.data(using: .utf8) else {
return
}
var query = keychainQuery(key: key)
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(query as CFDictionary, nil)
}
private func readKeychainValue(key: String) -> String? {
var query = keychainQuery(key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard
status == errSecSuccess,
let data = result as? Data
else {
return nil
}
return String(data: data, encoding: .utf8)
}
private func deleteKeychainValue(key: String) {
SecItemDelete(keychainQuery(key: key) as CFDictionary)
}
}

View File

@ -9,24 +9,6 @@ import 'package:yumi/app/routes/sc_lk_application.dart';
///
const Duration kAndroidTransitionDuration = Duration(milliseconds: 375);
///
const Duration kOpenRoomTransitionDuration = Duration(milliseconds: 325);
///
SlideTransition kOpenRoomTransitionBuilder( BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: const Offset(0.0, 0.0),
).animate(CurvedAnimation(parent: animation, curve: Curves.ease)),
child: child,
);
}
/// fluro的路由跳转工具类
class SCNavigatorUtils {
static bool inChatPage = false;

View File

@ -7,6 +7,7 @@ import 'package:yumi/modules/chat/chat_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/index/index_page.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
import 'package:yumi/modules/user/task/task_route.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/modules/auth/login_route.dart';
@ -46,6 +47,7 @@ class SCRoutes {
_listRouter.add(SettingsRoute());
_listRouter.add(VoiceRoomRoute());
_listRouter.add(SCMainRoute());
_listRouter.add(TaskRoute());
_listRouter.add(VipRoute());
_listRouter.add(WalletRoute());
_listRouter.add(StoreRoute());

View File

@ -218,6 +218,8 @@ class SCAppLocalizations {
String get claim => translate('claim');
String get claimed => translate('claimed');
String get inviteGoRoomTips => translate('inviteGoRoomTips');
String get dailyCoinBonanzaRules => translate('dailyCoinBonanzaRules');
@ -786,6 +788,19 @@ class SCAppLocalizations {
String get dailyTasks => translate('dailyTasks');
String get exclusiveForNewcomers => translate('exclusiveForNewcomers');
String get resetsDailyAtMidnight => translate('resetsDailyAtMidnight');
String get limitedToOneTime => translate('limitedToOneTime');
String get useMicrophoneForOneMin => translate('useMicrophoneForOneMin');
String get rewardClaimedSuccessfully =>
translate('rewardClaimedSuccessfully');
String get rewardClaimFailed => translate('rewardClaimFailed');
String get reject => translate('reject');
String get messageHasBeenRecalled => translate('messageHasBeenRecalled');

View File

@ -27,6 +27,7 @@ import 'app/routes/sc_routes.dart';
import 'app/routes/sc_lk_application.dart';
import 'shared/tools/sc_deep_link_handler.dart';
import 'shared/tools/sc_deviceId_utils.dart';
import 'shared/data_sources/sources/local/user_manager.dart';
import 'modules/splash/splash_page.dart';
import 'services/general/sc_app_general_manager.dart';
import 'services/payment/apple_payment_manager.dart';
@ -35,6 +36,7 @@ import 'services/gift/gift_animation_manager.dart';
import 'services/gift/gift_system_manager.dart';
import 'services/payment/google_payment_manager.dart';
import 'services/localization/localization_manager.dart';
import 'services/music/room_music_manager.dart';
import 'services/room/rc_room_manager.dart';
import 'services/audio/rtc_manager.dart';
import 'services/audio/rtm_manager.dart';
@ -93,6 +95,7 @@ Future<void> _prepareAppShell() async {
SCKeybordUtil.init();
// 使
await _initStore();
await AccountStorage().restoreDurableSessionIfNeeded();
await SCDeviceIdUtils.initDeviceinfo();
_configureImageCache();
}
@ -235,6 +238,10 @@ class RootAppWithProviders extends StatelessWidget {
lazy: true,
create: (context) => RealTimeMessagingManager(),
),
ChangeNotifierProvider<RoomMusicManager>(
lazy: true,
create: (context) => RoomMusicManager(),
),
// Provider -
ChangeNotifierProvider<SocialChatRoomManager>(

View File

@ -25,13 +25,17 @@ class SCRoomFollowPage extends SCPageList {
class _RoomFollowPageState
extends SCPageListState<FollowRoomRes, SCRoomFollowPage>
with WidgetsBindingObserver {
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId;
Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
@ -71,11 +75,12 @@ class _RoomFollowPageState
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
!_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer(
builder: _buildFollowSkeletonContent,
)
@ -264,10 +269,11 @@ class _RoomFollowPageState
),
),
onTap: () {
Provider.of<RtcProvider>(
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
listen: false,
).joinVoiceRoomSession(context, roomRes.roomProfile?.id ?? "");
roomRes.roomProfile?.id ?? "",
previewData: RoomEntryPreviewData.fromFollowRoom(roomRes),
);
},
);
}
@ -322,8 +328,14 @@ class _RoomFollowPageState
if (roomList.isNotEmpty) {
lastId = roomList.last.id;
}
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList);
} catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) {
onErr();
}
@ -380,9 +392,8 @@ class _RoomFollowPageState
}
bool changed = false;
final replaceCount = latestRooms.length < items.length
? latestRooms.length
: items.length;
final replaceCount =
latestRooms.length < items.length ? latestRooms.length : items.length;
for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i];

View File

@ -25,13 +25,17 @@ class SCRoomHistoryPage extends SCPageList {
class _SCRoomHistoryPageState
extends SCPageListState<FollowRoomRes, SCRoomHistoryPage>
with WidgetsBindingObserver {
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId;
Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
@ -71,11 +75,12 @@ class _SCRoomHistoryPageState
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
!_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer(
builder: _buildHistorySkeletonContent,
)
@ -264,10 +269,11 @@ class _SCRoomHistoryPageState
),
),
onTap: () {
Provider.of<RtcProvider>(
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
listen: false,
).joinVoiceRoomSession(context, roomRes.roomProfile?.id ?? "");
roomRes.roomProfile?.id ?? "",
previewData: RoomEntryPreviewData.fromFollowRoom(roomRes),
);
},
);
}
@ -322,8 +328,14 @@ class _SCRoomHistoryPageState
if (roomList.isNotEmpty) {
lastId = roomList.last.id;
}
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList);
} catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) {
onErr();
}
@ -380,9 +392,8 @@ class _SCRoomHistoryPageState
}
bool changed = false;
final replaceCount = latestRooms.length < items.length
? latestRooms.length
: items.length;
final replaceCount =
latestRooms.length < items.length ? latestRooms.length : items.length;
for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i];

View File

@ -22,11 +22,14 @@ class SCHomeMinePage extends StatefulWidget {
}
class _HomeMinePageState extends State<SCHomeMinePage>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late TabController _tabController;
final List<Widget> _pages = const [SCRoomHistoryPage(), SCRoomFollowPage()];
final List<Widget> _tabs = [];
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
@ -45,6 +48,7 @@ class _HomeMinePageState extends State<SCHomeMinePage>
@override
Widget build(BuildContext context) {
super.build(context);
_tabs.clear();
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.recent));
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.followed));
@ -234,16 +238,27 @@ class _HomeMinePageState extends State<SCHomeMinePage>
],
),
onTap: () {
String roomId =
Provider.of<SocialChatRoomManager>(
context,
listen: false,
).myRoom?.id ??
"";
final roomManager = Provider.of<SocialChatRoomManager>(
context,
listen: false,
);
final myRoom = roomManager.myRoom;
final roomId = myRoom?.id ?? "";
Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
).joinVoiceRoomSession(context, roomId);
).joinVoiceRoomSession(
context,
roomId,
previewData:
myRoom == null
? null
: RoomEntryPreviewData.fromMyRoom(
myRoom,
ownerProfile:
AccountStorage().getCurrentUser()?.userProfile,
),
);
},
)
: GestureDetector(

View File

@ -15,6 +15,7 @@ import '../../../../shared/business_logic/models/res/follow_room_res.dart';
import '../../../../shared/business_logic/models/res/room_res.dart';
import '../../../../services/audio/rtc_manager.dart';
import '../../../../services/general/sc_app_general_manager.dart';
import '../../../../services/home/home_room_preload_manager.dart';
import '../../../../ui_kit/components/sc_compontent.dart';
import '../../../../ui_kit/components/text/sc_text.dart';
import '../../../../ui_kit/widgets/room/room_live_audio_indicator.dart';
@ -36,7 +37,10 @@ class SCHomePartyPage extends StatefulWidget {
}
class _HomePartyPageState extends State<SCHomePartyPage>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
with
SingleTickerProviderStateMixin,
WidgetsBindingObserver,
AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
static const Duration _roomCounterHydrationMinGap = Duration(seconds: 20);
static const int _roomCounterHydrationLimit = 6;
@ -61,8 +65,12 @@ class _HomePartyPageState extends State<SCHomePartyPage>
bool _isSilentRefreshingRooms = false;
bool _isHydratingVisibleRoomCounters = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialRoomLoad = false;
DateTime? _lastRoomCounterHydrationAt;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
@ -101,9 +109,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
_wasTickerModeEnabled = isTickerModeEnabled;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: Stack(
@ -114,7 +123,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
enablePullUp: false,
controller: _refreshController,
onRefresh: () {
loadData();
loadData(forceRefresh: true);
},
onLoading: () {},
child: SingleChildScrollView(
@ -601,10 +610,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
);
}
Widget _buildRoomsSection() {
if (isLoading && rooms.isEmpty) {
return _buildRoomGridSkeleton();
}
Widget _buildRoomsSection() {
if (!_hasCompletedInitialRoomLoad && isLoading && rooms.isEmpty) {
return _buildRoomGridSkeleton();
}
if (rooms.isEmpty) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
@ -960,23 +969,24 @@ class _HomePartyPageState extends State<SCHomePartyPage>
);
}
loadData() {
final generalManager = Provider.of<SCAppGeneralManager>(
context,
listen: false,
);
setState(() {
isLoading = true;
_isBannerLoading =
generalManager.exploreBanners.isEmpty &&
generalManager.homeBanners.isEmpty;
_isLeaderboardLoading = generalManager.appLeaderResult == null;
});
SCChatRoomRepository()
.discovery(allRegion: true)
loadData({bool forceRefresh = false}) {
final generalManager = Provider.of<SCAppGeneralManager>(
context,
listen: false,
);
setState(() {
isLoading = true;
_isBannerLoading =
generalManager.exploreBanners.isEmpty &&
generalManager.homeBanners.isEmpty;
_isLeaderboardLoading = generalManager.appLeaderResult == null;
});
SCHomeRoomPreloadManager.instance
.loadPartyRooms(forceRefresh: forceRefresh)
.then((values) {
rooms = _mergeLatestRoomsWithCurrentCounters(values);
isLoading = false;
_hasCompletedInitialRoomLoad = true;
_refreshController.refreshCompleted();
_refreshController.loadComplete();
if (mounted) {
@ -986,10 +996,11 @@ class _HomePartyPageState extends State<SCHomePartyPage>
})
.catchError((e) {
_refreshController.loadNoData();
_refreshController.refreshCompleted();
isLoading = false;
if (mounted) setState(() {});
});
_refreshController.refreshCompleted();
isLoading = false;
_hasCompletedInitialRoomLoad = true;
if (mounted) setState(() {});
});
generalManager.loadMainBanner().whenComplete(() {
if (!mounted) {
return;
@ -1010,7 +1021,9 @@ class _HomePartyPageState extends State<SCHomePartyPage>
_buildItem(SocialChatRoomRes res, int index) {
final rankBorderAsset =
index < _topRankBorderAssets.length ? _topRankBorderAssets[index] : null;
index < _topRankBorderAssets.length
? _topRankBorderAssets[index]
: null;
final rankBorderOverflow = 12.w;
final rankInfoHorizontalInset = rankBorderAsset != null ? 10.w : 0.w;
final rankInfoBottomInset = rankBorderAsset != null ? 9.w : 0.w;
@ -1164,11 +1177,12 @@ class _HomePartyPageState extends State<SCHomePartyPage>
),
),
onTap: () {
Provider.of<RtcProvider>(
context,
listen: false,
).joinVoiceRoomSession(context, res.id ?? "");
},
);
}
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
res.id ?? "",
previewData: RoomEntryPreviewData.fromSocialChatRoom(res),
);
},
);
}
}

View File

@ -39,6 +39,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
int _currentIndex = 0;
final List<Widget> _pages = [];
final Set<int> _builtPageIndexes = <int>{0};
final List<BottomNavigationBarItem> _bottomItems = [];
SCAppGeneralManager? generalProvider;
Locale? _lastLocale;
@ -61,6 +62,14 @@ class _SCIndexPageState extends State<SCIndexPage> {
context,
listen: false,
).initializeRealTimeCommunicationManager(context);
unawaited(
Provider.of<RtcProvider>(
context,
listen: false,
).prewarmRtcEngine().catchError((error, stackTrace) {
debugPrint('[Agora] prewarm on index failed: $error');
}),
);
Provider.of<RtmProvider>(context, listen: false).init(context);
Provider.of<SocialChatUserProfileManager>(
context,
@ -122,7 +131,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: _pages[_currentIndex],
body: _buildCachedBody(),
bottomNavigationBar: Container(
height: 85.w,
decoration: BoxDecoration(
@ -159,7 +168,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
showSelectedLabels: true,
items: _bottomItems,
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
onTap: _switchBottomTab,
),
),
),
@ -197,6 +206,31 @@ class _SCIndexPageState extends State<SCIndexPage> {
_pages.add(MePage2());
}
Widget _buildCachedBody() {
return IndexedStack(
index: _currentIndex,
children: List<Widget>.generate(_pages.length, (index) {
if (!_builtPageIndexes.contains(index)) {
return const SizedBox.shrink();
}
return TickerMode(
enabled: index == _currentIndex,
child: _pages[index],
);
}),
);
}
void _switchBottomTab(int index) {
if (index == _currentIndex) {
return;
}
setState(() {
_currentIndex = index;
_builtPageIndexes.add(index);
});
}
void _rebuildBottomItemsIfNeeded() {
final locale = Localizations.localeOf(context);
if (_lastLocale == locale && _bottomItems.isNotEmpty) {

View File

@ -591,6 +591,24 @@ class _RoomEditPageState extends State<RoomEditPage> {
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ??
"",
clearRoomData: true,
previewData: RoomEntryPreviewData(
roomId:
mergedRoomInfo.id ??
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id,
roomAccount:
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomAccount,
userId: rtcProvider?.currenRoom?.roomProfile?.roomProfile?.userId,
roomCover: mergedRoomInfo.roomCover,
roomName: mergedRoomInfo.roomName ?? submittedRoomName,
roomDesc: mergedRoomInfo.roomDesc ?? submittedRoomDesc,
roomSetting: rtcProvider?.currenRoom?.roomProfile?.roomSetting,
ownerProfile: rtcProvider?.currenRoom?.roomProfile?.userProfile,
roomAdminCount:
rtcProvider?.currenRoom?.roomProfile?.roomCounter?.adminCount,
roomMemberCount:
rtcProvider?.currenRoom?.roomProfile?.roomCounter?.memberCount,
entrantsRole: rtcProvider?.currenRoom?.entrants?.roles,
),
);
} else {
SCLoadingManager.hide();

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/modules/room/music/room_music_select_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
class RoomMusicFolderPage extends StatelessWidget {
const RoomMusicFolderPage({super.key, required this.folders});
final List<SCMusicFolderMode> folders;
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
appBar: AppBar(
backgroundColor: const Color(0xFF071F1B),
elevation: 0,
title: Text(
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
),
),
body: ListView.separated(
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 20.w),
itemCount: folders.length,
separatorBuilder:
(_, __) => Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 1,
),
itemBuilder: (context, index) {
final folder = folders[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final changed = await Navigator.of(
context,
).push<bool>(_musicRoute(RoomMusicSelectPage(folder: folder)));
if (changed == true && context.mounted) {
Navigator.pop(context, true);
}
},
child: SizedBox(
height: 82.w,
child: Row(
children: [
Image.asset(
"sc_images/room/sc_music_material_folder.png",
width: 44.w,
height: 44.w,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
folder.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
if (folder.addedCount > 0)
Text(
RoomMusicTexts.t(
context,
"roomMusicAddedCount",
"已添加 ${folder.addedCount}",
).replaceAll(
"{1}",
folder.addedCount.toString(),
),
style: TextStyle(
color: const Color(0xFF33E6A2),
fontSize: 11.sp,
),
),
],
),
SizedBox(height: 6.w),
Text(
"${folder.musicCount} Music | ${folder.path}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
SizedBox(width: 8.w),
Icon(
Icons.chevron_right,
color: Colors.white.withValues(alpha: 0.55),
),
],
),
),
);
},
),
),
);
}
}
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}

View File

@ -0,0 +1,629 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_folder_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/local_music_scanner.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_player_bar.dart';
class RoomMusicPage extends StatefulWidget {
const RoomMusicPage({super.key});
@override
State<RoomMusicPage> createState() => _RoomMusicPageState();
}
class _RoomMusicPageState extends State<RoomMusicPage> {
final TextEditingController _searchController = TextEditingController();
final LocalMusicScanner _scanner = LocalMusicScanner();
bool _isScanning = false;
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final manager = context.read<RoomMusicManager>();
manager.attachRtcProvider(context.read<RtcProvider>());
manager.reload();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
body: SafeArea(
child: Stack(
children: [
Column(
children: [
_buildHeader(context),
Expanded(
child: Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final songs = _filteredSongs(manager.playlist);
if (manager.playlist.isEmpty) {
return _buildEmptyState(context);
}
return Column(
children: [
_buildSearchField(),
_buildTotal(context, manager.playlist.length),
Expanded(
child:
_isEditing && _searchController.text.isEmpty
? _buildReorderableList(manager, songs)
: _buildSongList(manager, songs),
),
],
);
},
),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: const RoomMusicPlayerBar(),
),
if (_isScanning)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: CircularProgressIndicator(
color: Color(0xFF33E6A2),
),
),
),
),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 54.w,
child: Row(
children: [
_PlainIconButton(
onPressed: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 24.w,
),
),
Expanded(
child: Text(
RoomMusicTexts.t(context, "music", "音乐"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
),
),
),
_PlainIconButton(
onPressed: _startAddFlow,
child: Image.asset(
"sc_images/room/sc_icon_room_music_add.png",
width: 24.w,
height: 24.w,
),
),
],
),
);
}
Widget _buildSearchField() {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 14.w),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
style: TextStyle(color: Colors.white, fontSize: 13.sp),
decoration: InputDecoration(
isDense: true,
hintText: RoomMusicTexts.t(context, "search", "搜索"),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.42),
fontSize: 13.sp,
),
prefixIcon: Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.42),
size: 20.w,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.08),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(17.w),
borderSide: BorderSide.none,
),
),
),
);
}
Widget _buildTotal(BuildContext context, int count) {
final template = RoomMusicTexts.t(
context,
"roomMusicTotalSongs",
"总计:{1} 首歌曲",
);
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 8.w),
child: Row(
children: [
Expanded(
child: Text(
template.replaceAll("{1}", count.toString()),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.90),
fontSize: 14.sp,
),
),
),
_PlainIconButton(
onPressed:
_searchController.text.trim().isEmpty
? () {
setState(() {
_isEditing = !_isEditing;
});
}
: null,
child: Image.asset(
"sc_images/room/sc_music_material_edit.png",
width: 24.w,
height: 24.w,
color:
_isEditing
? const Color(0xFF33E6A2)
: Colors.white.withValues(
alpha: _searchController.text.trim().isEmpty ? 1 : 0.36,
),
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.only(bottom: 120.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
"sc_images/room/sc_icon_room_music_empty.png",
width: 86.w,
height: 86.w,
),
SizedBox(height: 12.w),
Text(
RoomMusicTexts.t(context, "roomMusicEmpty", "没有音乐"),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 18.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _startAddFlow,
child: Container(
width: 164.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF33E6A2),
borderRadius: BorderRadius.circular(22.w),
),
child: Text(
RoomMusicTexts.t(
context,
"roomMusicScanFromPhone",
"从手机扫描添加",
),
style: TextStyle(
color: const Color(0xFF06251F),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
Widget _buildSongList(RoomMusicManager manager, List<SCMusicMode> songs) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
itemCount: songs.length,
itemBuilder: (context, index) {
final item = songs[index];
return Padding(
padding: EdgeInsets.only(bottom: 8.w),
child: _SwipeDeleteTile(
key: ValueKey("music-${item.id}"),
onDelete: () {
_confirmDelete(item);
},
child: _SongTile(
item: item,
selected: manager.current?.id == item.id,
playing: manager.current?.id == item.id && manager.isPlaying,
onTap: () => manager.play(item),
),
),
);
},
);
}
Widget _buildReorderableList(
RoomMusicManager manager,
List<SCMusicMode> songs,
) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ReorderableListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
buildDefaultDragHandles: false,
itemCount: songs.length,
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: child,
);
},
onReorder: (oldIndex, newIndex) {
manager.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = songs[index];
return Padding(
key: ValueKey("reorder-${item.id}"),
padding: EdgeInsets.only(bottom: 8.w),
child: _SongTile(
item: item,
selected: manager.current?.id == item.id,
playing: manager.current?.id == item.id && manager.isPlaying,
onTap: () => manager.play(item),
trailing: ReorderableDragStartListener(
index: index,
child: Image.asset(
"sc_images/room/sc_music_material_more.png",
width: 24.w,
height: 24.w,
),
),
),
);
},
);
}
Future<void> _startAddFlow() async {
if (_isScanning) {
return;
}
setState(() {
_isScanning = true;
});
final manager = context.read<RoomMusicManager>();
final folders = await _scanner.scanMp3Folders(addedSongs: manager.playlist);
if (!mounted) {
return;
}
setState(() {
_isScanning = false;
});
final songCount = folders.fold<int>(
0,
(sum, item) => sum + item.musicCount,
);
if (songCount == 0) {
SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
return;
}
final changed = await Navigator.of(
context,
).push<bool>(_musicRoute(RoomMusicFolderPage(folders: folders)));
if (changed == true && mounted) {
await manager.reload();
}
}
Future<bool> _confirmDelete(SCMusicMode item) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: const Color(0xFF10352E),
title: Text(
RoomMusicTexts.t(context, "roomMusicDeleteConfirm", "确定删除音乐?"),
style: const TextStyle(color: Colors.white),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(RoomMusicTexts.t(context, "cancel", "取消")),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(RoomMusicTexts.t(context, "confirm", "确认")),
),
],
);
},
);
if (result == true && mounted) {
final deletedText = RoomMusicTexts.t(
context,
"roomMusicDeleted",
"音乐已删除",
);
final manager = context.read<RoomMusicManager>();
await manager.deleteMusic(item);
SCTts.show(deletedText);
}
return false;
}
List<SCMusicMode> _filteredSongs(List<SCMusicMode> songs) {
final keyword = _searchController.text.trim().toLowerCase();
if (keyword.isEmpty) {
return songs;
}
return songs.where((item) {
return item.title.toLowerCase().contains(keyword) ||
item.fileName.toLowerCase().contains(keyword);
}).toList();
}
}
class _SongTile extends StatelessWidget {
const _SongTile({
required this.item,
required this.selected,
required this.playing,
required this.onTap,
this.trailing,
});
final SCMusicMode item;
final bool selected;
final bool playing;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB2FBCC), Color(0x00B2FBCC)],
stops: [0, 1],
),
borderRadius: BorderRadius.circular(8.w),
),
child: Container(
height: 54.w,
margin: const EdgeInsets.all(1),
padding: EdgeInsetsDirectional.fromSTEB(12.w, 0, 12.w, 0),
decoration: BoxDecoration(
color: const Color(0xFF08251E),
borderRadius: BorderRadius.circular(7.w),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
selected ? const Color(0xFF33E6A2) : Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 5.w),
Text(
_formatDuration(item.durationMs),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
if (trailing != null) ...[
SizedBox(width: 10.w),
trailing!,
] else
Image.asset(
playing
? "sc_images/room/sc_music_material_pause.png"
: "sc_images/room/sc_music_material_play.png",
width: 26.w,
height: 26.w,
),
],
),
),
),
);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}
class _SwipeDeleteTile extends StatefulWidget {
const _SwipeDeleteTile({
super.key,
required this.child,
required this.onDelete,
});
final Widget child;
final VoidCallback onDelete;
@override
State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> {
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
final actionWidth = 60.w;
return LayoutBuilder(
builder: (context, constraints) {
return ClipRect(
child: SizedBox(
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
setState(() {
_dragOffset = (_dragOffset + details.delta.dx).clamp(
-actionWidth,
0,
);
});
},
onHorizontalDragEnd: (_) {
setState(() {
_dragOffset =
_dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: Row(
children: [
SizedBox(width: constraints.maxWidth, child: widget.child),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
),
),
),
),
],
),
),
),
),
);
},
);
}
}
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({required this.child, required this.onPressed});
final Widget child;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(width: 48.w, height: 48.w, child: Center(child: child)),
);
}
}

View File

@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class RoomMusicSelectPage extends StatefulWidget {
const RoomMusicSelectPage({super.key, required this.folder});
final SCMusicFolderMode folder;
@override
State<RoomMusicSelectPage> createState() => _RoomMusicSelectPageState();
}
class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
late Set<String> _initialSelectedIds;
late Set<String> _selectedIds;
bool get _hasChanged => !_setEquals(_initialSelectedIds, _selectedIds);
bool get _allSelected => _selectedIds.length == widget.folder.songs.length;
@override
void initState() {
super.initState();
final currentIds =
context
.read<RoomMusicManager>()
.playlist
.map((item) => item.id)
.toSet();
_initialSelectedIds =
widget.folder.songs
.where((item) => currentIds.contains(item.id))
.map((item) => item.id)
.toSet();
_selectedIds = {..._initialSelectedIds};
}
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
appBar: AppBar(
backgroundColor: const Color(0xFF071F1B),
elevation: 0,
title: Text(
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
),
),
body: Column(
children: [
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 16.w),
itemCount: widget.folder.songs.length,
separatorBuilder:
(_, __) => Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 1,
),
itemBuilder: (context, index) {
return _buildSongItem(widget.folder.songs[index]);
},
),
),
_buildBottomBar(context),
],
),
),
);
}
Widget _buildSongItem(SCMusicMode item) {
final selected = _selectedIds.contains(item.id);
final alreadyAdded = _initialSelectedIds.contains(item.id);
final checkColor =
!selected
? Colors.transparent
: alreadyAdded
? Colors.white.withValues(alpha: 0.34)
: const Color(0xFF33E6A2);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
if (selected) {
_selectedIds.remove(item.id);
} else {
_selectedIds.add(item.id);
}
});
},
child: SizedBox(
height: 64.w,
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 5.w),
Text(
_formatDuration(item.durationMs),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
Container(
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: checkColor,
border: Border.all(
color:
selected
? checkColor
: Colors.white.withValues(alpha: 0.28),
),
),
child:
selected
? Icon(Icons.check, size: 15.w, color: Colors.white)
: null,
),
],
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xFF082820),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
),
child: Row(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
if (_allSelected) {
_selectedIds.clear();
} else {
_selectedIds =
widget.folder.songs.map((item) => item.id).toSet();
}
});
},
child: Row(
children: [
Icon(
_allSelected
? Icons.check_circle
: Icons.radio_button_unchecked,
color:
_allSelected
? const Color(0xFF33E6A2)
: Colors.white.withValues(alpha: 0.55),
size: 22.w,
),
SizedBox(width: 8.w),
Text(
RoomMusicTexts.t(context, "roomMusicSelectAll", "全选"),
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
const Spacer(),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _hasChanged ? _submit : null,
child: Container(
width: 104.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color:
_hasChanged
? const Color(0xFF33E6A2)
: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20.w),
),
child: Text(
RoomMusicTexts.t(context, "add", "添加"),
style: TextStyle(
color:
_hasChanged
? const Color(0xFF06251F)
: Colors.white.withValues(alpha: 0.38),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
Future<void> _submit() async {
await context.read<RoomMusicManager>().applyFolderSelection(
folder: widget.folder,
selectedIds: _selectedIds,
);
if (!mounted) {
return;
}
Navigator.pop(context, true);
}
bool _setEquals(Set<String> a, Set<String> b) {
return a.length == b.length && a.containsAll(b);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/widgets.dart';
import 'package:yumi/app_localizations.dart';
class RoomMusicTexts {
const RoomMusicTexts._();
static String t(BuildContext context, String key, String fallback) {
final value = SCAppLocalizations.of(context)?.translate(key) ?? key;
return value == key ? fallback : value;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import 'dart:math' as math;
import 'dart:async';
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';
@ -15,6 +14,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
@ -30,6 +30,7 @@ import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_room_player.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
@ -64,6 +65,8 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
final Map<String, _LuckyGiftComboSession> _luckyGiftComboSessions =
<String, _LuckyGiftComboSession>{};
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
@override
void initState() {
@ -71,15 +74,6 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_tabController = TabController(length: _pages.length, vsync: this);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final rtcProvider = context.read<RtcProvider>();
rtcProvider.requestMicrophoneListRefresh(notifyIfUnchanged: true);
rtcProvider.fetchOnlineUsersList(notifyIfUnchanged: true);
});
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,
) {
@ -98,11 +92,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rtcProvider = context.read<RtcProvider>();
if (!identical(_rtcProvider, rtcProvider)) {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = rtcProvider;
rtcProvider.addListener(_handleRoomProviderChanged);
}
_ensureRoomVisualEffectsEnabled();
}
@override
void dispose() {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = null;
_roomProviderRebuildScheduled = false;
_suspendRoomVisualEffects();
_tabController.removeListener(_handleTabChange);
_tabController.dispose(); //
@ -117,6 +120,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
setState(() {});
}
void _handleRoomProviderChanged() {
if (!mounted || _roomProviderRebuildScheduled) {
return;
}
_roomProviderRebuildScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomProviderRebuildScheduled = false;
if (!mounted) {
return;
}
setState(() {});
});
}
void _enableRoomVisualEffects() {
Provider.of<RtcProvider>(
context,
@ -156,6 +173,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
}
Widget _buildRoomStartupLayer() {
@ -174,24 +194,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
if (snapshot.status != RoomStartupStatus.loading) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: AbsorbPointer(
child: Container(
color: Colors.black.withValues(alpha: 0.18),
alignment: Alignment.center,
child: Container(
width: 55.w,
height: 55.w,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: const CupertinoActivityIndicator(color: Colors.white24),
),
),
),
);
return const SizedBox.shrink();
},
);
}
@ -324,6 +327,12 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
],
),
_buildRoomStartupLayer(),
const PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: RoomMusicRoomPlayer(),
),
// _buildPlayViews(),
///
LuckGiftNomorAnimWidget(),

View File

@ -7,6 +7,7 @@ import 'package:yumi/modules/room/background/room_background_preview_page.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart';
import 'package:yumi/modules/room/music/room_music_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
@ -18,12 +19,64 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomBackgroundSelect = '/room/background/select';
static String roomBackgroundPreview = '/room/background/preview';
static String roomBackgroundUpload = '/room/background/upload';
static String roomMusic = '/room/music';
static int _voiceRoomRouteDepth = 0;
static Route<T> _buildRoute<T>(Widget page) {
static bool get isVoiceRoomOpen => _voiceRoomRouteDepth > 0;
static Route<T> _buildRoute<T>(Widget page, {RouteSettings? settings}) {
if (Platform.isIOS) {
return CupertinoPageRoute<T>(builder: (_) => page);
return CupertinoPageRoute<T>(builder: (_) => page, settings: settings);
}
return MaterialPageRoute<T>(builder: (_) => page);
return MaterialPageRoute<T>(builder: (_) => page, settings: settings);
}
static Route<T> _buildDarkRoute<T>(Widget page, {RouteSettings? settings}) {
return PageRouteBuilder<T>(
settings: settings,
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
static Future<T?> openVoiceRoom<T>(
BuildContext context, {
bool replace = false,
bool rootNavigator = false,
}) {
final navigator = Navigator.of(context, rootNavigator: rootNavigator);
if (!replace && isVoiceRoomOpen) {
debugPrint('[RoomRoute] skip duplicate voice room route');
return Future<T?>.value();
}
final route = _buildRoute<T>(
const VoiceRoomPage(),
settings: RouteSettings(name: voiceRoom),
);
_voiceRoomRouteDepth = replace ? 1 : _voiceRoomRouteDepth + 1;
Future<T?> routeFuture;
if (replace) {
routeFuture = navigator.pushReplacement<T, T>(route);
} else {
routeFuture = navigator.push<T>(route);
}
return routeFuture.whenComplete(() {
if (_voiceRoomRouteDepth > 0) {
_voiceRoomRouteDepth -= 1;
}
});
}
static Future<T?> openRoomEdit<T>(
@ -54,20 +107,24 @@ class VoiceRoomRoute implements SCIRouterProvider {
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundSelectPage()));
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomBackgroundSelectPage(),
settings: RouteSettings(name: roomBackgroundSelect),
),
);
}
static Future<T?> openRoomBackgroundUpload<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundUploadPage()));
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomBackgroundUploadPage(),
settings: RouteSettings(name: roomBackgroundUpload),
),
);
}
static Future<T?> openRoomBackgroundPreview<T>(
@ -76,7 +133,22 @@ class VoiceRoomRoute implements SCIRouterProvider {
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildRoute<T>(RoomBackgroundPreviewPage(backgroundPath: backgroundPath)),
_buildDarkRoute<T>(
RoomBackgroundPreviewPage(backgroundPath: backgroundPath),
settings: RouteSettings(name: roomBackgroundPreview),
),
);
}
static Future<T?> openRoomMusic<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomMusicPage(),
settings: RouteSettings(name: roomMusic),
),
);
}
@ -108,6 +180,10 @@ class VoiceRoomRoute implements SCIRouterProvider {
handlerFunc: (_, params) => const RoomBackgroundUploadPage(),
),
);
router.define(
roomMusic,
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
);
router.define(
roomEdit,
handler: Handler(

View File

@ -346,24 +346,23 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
return 0;
}
double _calculateBodyHeight(BuildContext context, double headerHeight) {
double _calculateBodyHeight(BuildContext context) {
final screenMode = _resolveScreenMode();
final maxDialogHeight = resolveRoomGameAvailableHeight(
context,
reservedTop: 24.w,
reservedTop: 12.w,
);
final maxBodyHeight = maxDialogHeight - headerHeight;
if (maxBodyHeight <= 0) {
if (maxDialogHeight <= 0) {
return 0;
}
if (screenMode == 'full') {
return maxBodyHeight;
return maxDialogHeight;
}
return clampRoomGameHeight(
_calculatePreferredBodyHeight(context),
maxBodyHeight,
maxDialogHeight,
);
}
@ -544,8 +543,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
@override
Widget build(BuildContext context) {
final headerHeight = 44.w;
final bodyHeight = _calculateBodyHeight(context, headerHeight);
final bodyHeight = _calculateBodyHeight(context);
final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context);
return PopScope(
canPop: false,
@ -566,7 +564,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
),
child: Container(
width: ScreenUtil().screenWidth,
height: bodyHeight + headerHeight + bottomFrameHeight,
height: bodyHeight + bottomFrameHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
@ -574,13 +572,6 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
left: 0,
right: 0,
top: 0,
height: headerHeight,
child: _buildHeader(),
),
Positioned(
left: 0,
right: 0,
top: headerHeight,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
@ -614,36 +605,6 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
);
}
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(

View File

@ -434,12 +434,11 @@ class _SearchRoomListState extends State<SearchRoomList> {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (context != null) {
Provider.of<RtcProvider>(
context,
listen: false,
).joinVoiceRoomSession(context, e.id ?? "");
}
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
e.id ?? "",
previewData: RoomEntryPreviewData.fromSocialChatRoom(e),
);
},
child: Stack(
alignment: Alignment.bottomCenter,
@ -543,9 +542,9 @@ class _SearchRoomListState extends State<SearchRoomList> {
width: 20.w,
height: 20.w,
),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? SizedBox(width: 3.w)
: Container(height: 10.w),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? SizedBox(width: 3.w)
: Container(height: 10.w),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? text(
e.displayMemberCount,

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
@ -6,9 +7,14 @@ 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/shared/tools/sc_version_utils.dart';
import 'package:yumi/shared/tools/sc_google_auth_utils.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_auth_type.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/services/home/home_room_preload_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
@ -90,6 +96,7 @@ class _SplashPageState extends State<SplashPage> {
_selectedVariant = _pickSplashVariant();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(FileCacheManager.getInstance().getFilePath());
SCHomeRoomPreloadManager.instance.preloadInitialHomeRooms();
unawaited(WeeklyStarSplashCache.refreshCacheInBackground());
unawaited(LastWeeklyCPSplashCache.refreshCacheInBackground());
});
@ -448,6 +455,15 @@ class _SplashPageState extends State<SplashPage> {
var token = AccountStorage().getToken();
if (user != null && token.isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
return;
}
final restoredUser = await _tryRestoreLoginSession();
if (!mounted) {
return;
}
if (restoredUser != null &&
(restoredUser.token ?? "").trim().isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
} else {
SCNavigatorUtils.pushLoginIfNeeded(context, replace: true);
}
@ -459,6 +475,28 @@ class _SplashPageState extends State<SplashPage> {
}
}
Future<SocialChatLoginRes?> _tryRestoreLoginSession() async {
if (!Platform.isAndroid) {
return null;
}
try {
final googleUser = await SCGoogleAuthService.signInSilently();
final uid = googleUser?.uid ?? "";
if (uid.isEmpty) {
return null;
}
final user = await SCAccountRepository().loginForChannel(
SCAuthType.GOOGLE.name,
uid,
);
AccountStorage().setCurrentUser(user);
return user;
} catch (e) {
debugPrint('restore login session failed: $e');
return null;
}
}
///
void _cancelTimer() {
// `Timer``cancel`

View File

@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
@ -15,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import '../../../../shared/data_sources/models/enum/sc_level_type.dart';
class UserLevelPage extends StatefulWidget {
const UserLevelPage({super.key});
@override
_UserLevelPageState createState() => _UserLevelPageState();
State<UserLevelPage> createState() => _UserLevelPageState();
}
class _UserLevelPageState extends State<UserLevelPage> {
@ -78,26 +79,26 @@ class _UserLevelPageState extends State<UserLevelPage> {
maxWidth: 115.w,
fontWeight: FontWeight.bold,
AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname ??
.getCurrentUser()
?.userProfile
?.userNickname ??
"",
fontSize: 15.sp,
type:
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
"",
needScroll:
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
10,
),
SizedBox(width: 3.w),
@ -162,7 +163,6 @@ class _UserLevelPageState extends State<UserLevelPage> {
textColor: Colors.white,
),
Image.asset("sc_images/level/sc_icon_user_level_center_bg_1.png"),
Image.asset("sc_images/level/sc_icon_user_level_center_bg_2.png"),
text(
SCAppLocalizations.of(context)!.howToUpgrade,
fontSize: 20.sp,
@ -171,7 +171,9 @@ class _UserLevelPageState extends State<UserLevelPage> {
),
SizedBox(height: 3),
text(
SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints,
SCAppLocalizations.of(
context,
)!.spendCoinsToGainExperiencePoints,
fontSize: 12.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white,
@ -196,7 +198,7 @@ class _UserLevelPageState extends State<UserLevelPage> {
SCNavigatorUtils.push(context, StoreRoute.list);
},
),
SizedBox(height: 20.w,)
SizedBox(height: 20.w),
],
),
)

View File

@ -1,7 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:marquee/marquee.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
@ -16,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import '../../../../shared/data_sources/models/enum/sc_level_type.dart';
class WealthLevelPage extends StatefulWidget {
const WealthLevelPage({super.key});
@override
_WealthLevelPageState createState() => _WealthLevelPageState();
State<WealthLevelPage> createState() => _WealthLevelPageState();
}
class _WealthLevelPageState extends State<WealthLevelPage> {
@ -79,26 +79,26 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
maxWidth: 125.w,
fontWeight: FontWeight.bold,
AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname ??
.getCurrentUser()
?.userProfile
?.userNickname ??
"",
fontSize: 15.sp,
type:
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
"",
needScroll:
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
10,
),
SizedBox(width: 3.w),
@ -163,16 +163,17 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
textColor: Colors.white,
),
Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_1.png"),
Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_2.png"),
text(
SCAppLocalizations.of(context)!.howToUpgrade,
fontSize: 20.sp,
fontWeight: FontWeight.bold,
textColor:SocialChatTheme.primaryLight,
textColor: SocialChatTheme.primaryLight,
),
SizedBox(height: 3),
text(
SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints,
SCAppLocalizations.of(
context,
)!.spendCoinsToGainExperiencePoints,
fontSize: 12.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white,
@ -197,7 +198,7 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
SCNavigatorUtils.push(context, StoreRoute.list);
},
),
SizedBox(height: 20.w,)
SizedBox(height: 20.w),
],
),
)

View File

@ -7,11 +7,14 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
@ -30,6 +33,8 @@ class _MePage2State extends State<MePage2> {
static const double _inviteActivityHorizontalScale = 750 / 662;
Map<String, SCUserCounterRes> _counterMap = {};
SCVipStatusRes? _vipStatus;
bool _isVipStatusLoading = false;
@override
void initState() {
@ -47,6 +52,7 @@ class _MePage2State extends State<MePage2> {
profileManager.fetchUserProfileData();
profileManager.balance();
_loadCounter();
_loadVipStatus();
}
Future<void> _loadCounter() async {
@ -66,6 +72,55 @@ class _MePage2State extends State<MePage2> {
} catch (_) {}
}
Future<void> _loadVipStatus() async {
if (mounted) {
setState(() => _isVipStatusLoading = true);
}
try {
final status = await _loadVipEntryStatus();
debugPrint(
'[VIP][MeEntry] status loaded active=${status.active} '
'level=${status.level} levelCode=${status.levelCode} '
'expireAt=${status.expireAt} '
'badge=${_vipEntryBadgeLogValue(status.badge)}',
);
if (!mounted) return;
setState(() {
_vipStatus = status;
_isVipStatusLoading = false;
});
} catch (error) {
debugPrint('[VIP][MeEntry] status load failed error=$error');
if (!mounted) return;
setState(() => _isVipStatusLoading = false);
}
}
Future<SCVipStatusRes> _loadVipEntryStatus() async {
final repository = SCVipRepositoryImp();
try {
final home = await repository.vipHome();
final state = home.state;
if (state != null && state.levelInt > 0) {
debugPrint('[VIP][MeEntry] use vip home state');
return state;
}
debugPrint('[VIP][MeEntry] vip home state empty, fallback status');
} catch (error) {
debugPrint('[VIP][MeEntry] vip home load failed error=$error');
}
return repository.vipStatus();
}
void _refreshAfterVipReturn(SocialChatUserProfileManager profileManager) {
debugPrint('[VIP][MeEntry] refresh user data after vip page return');
profileManager.fetchUserProfileData(loadGuardCount: false);
profileManager.balance();
_loadCounter();
_loadVipStatus();
}
void _openInviteActivity() {
final inviteActivityUrl = _appendToken(_inviteActivityUrl);
if (inviteActivityUrl.isEmpty) return;
@ -106,7 +161,7 @@ class _MePage2State extends State<MePage2> {
SizedBox(height: 12.w),
_buildEntryRow(),
SizedBox(height: 12.w),
_buildWalletCard(),
_buildWalletVipRow(profileManager),
_buildInviteActivityBanner(),
_buildMenuCard2(),
SizedBox(height: 12.w),
@ -254,12 +309,21 @@ class _MePage2State extends State<MePage2> {
);
}
Widget _buildWalletVipRow(SocialChatUserProfileManager profileManager) {
return Row(
children: [
Expanded(child: _buildVipEntryCard(profileManager)),
SizedBox(width: 10.w),
Expanded(child: _buildWalletCard()),
],
);
}
Widget _buildWalletCard() {
return SCDebounceWidget(
onTap: () => SCNavigatorUtils.push(context, WalletRoute.recharge),
child: Container(
width: double.infinity,
height: 90.w,
height: 68.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
image: const DecorationImage(
@ -267,44 +331,53 @@ class _MePage2State extends State<MePage2> {
fit: BoxFit.fill,
),
),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w),
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
SCAppLocalizations.of(context)!.wallet,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 24.sp,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
color: Colors.white,
height: 1,
),
),
SizedBox(height: 4.w),
SizedBox(height: 2.w),
Row(
children: [
Image.asset(
'sc_images/general/sc_icon_jb.png',
width: 28.w,
height: 28.w,
'sc_images/general/sc_icon_wallet_coin.png',
width: 18.w,
height: 18.w,
),
SizedBox(width: 6.w),
Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
return Text(
':${_balanceText(ref.myBalance)}',
style: TextStyle(
color: Colors.white,
fontSize: 24.sp,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
),
);
},
SizedBox(width: 4.w),
Expanded(
child: Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
return Text(
':${_balanceText(ref.myBalance)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
height: 1,
),
);
},
),
),
],
),
@ -313,8 +386,8 @@ class _MePage2State extends State<MePage2> {
),
Image.asset(
'sc_images/index/sc_icon_wallet_icon.png',
width: 68.w,
height: 68.w,
width: 40.w,
height: 40.w,
),
],
),
@ -322,6 +395,191 @@ class _MePage2State extends State<MePage2> {
);
}
Widget _buildVipEntryCard(SocialChatUserProfileManager profileManager) {
final vipLevel = _vipEntryLevel(profileManager);
final isActiveVip = vipLevel > 0;
final badgeResource = isActiveVip ? _vipStatus?.badge : null;
return SCDebounceWidget(
onTap: () async {
await SCNavigatorUtils.push(context, VipRoute.detail);
if (mounted) {
_refreshAfterVipReturn(profileManager);
}
},
child: Container(
height: 68.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
border: Border.all(color: const Color(0xFFFFD37A), width: 1.w),
image: const DecorationImage(
image: AssetImage('sc_images/vip/sc_vip_entry_bg.png'),
fit: BoxFit.fill,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 10.w,
offset: Offset(0, 4.w),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
PositionedDirectional(
top: 12.w,
start: 14.w,
child: Text(
'VIP',
style: TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontWeight: FontWeight.w700,
height: 1,
),
),
),
PositionedDirectional(
start: 13.w,
bottom: 10.w,
child:
_isVipStatusLoading && _vipStatus == null
? SizedBox(
width: 16.w,
height: 16.w,
child: CircularProgressIndicator(
color: const Color(0xFFFFD155),
strokeWidth: 2.w,
),
)
: isActiveVip
? _buildVipLevelMark(vipLevel)
: Text(
'Not activated',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(
0xFFFFE8A7,
).withValues(alpha: 0.82),
fontSize: 11.sp,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
PositionedDirectional(
end: 2.w,
top: 8.w,
child: Opacity(
opacity: isActiveVip ? 1 : 0.52,
child: _buildVipEntryBadge(resource: badgeResource),
),
),
],
),
),
);
}
Widget _buildVipEntryBadge({required SCVipResourceRes? resource}) {
final empty = SizedBox(width: 52.w, height: 52.w);
final url = resource?.previewUrl?.trim();
if (url == null || url.isEmpty) {
return empty;
}
return netImage(
url: url,
width: 52.w,
height: 52.w,
fit: BoxFit.contain,
noDefaultImg: true,
errorWidget: empty,
);
}
String _vipEntryBadgeLogValue(SCVipResourceRes? resource) {
if (resource == null) {
return 'null';
}
return '{id:${resource.resourceId},name:${resource.name},'
'hasPreviewUrl:${resource.hasPreviewUrl},'
'previewUrl:${resource.previewUrl}}';
}
int _vipEntryLevel(SocialChatUserProfileManager profileManager) {
final status = _vipStatus;
if (status != null && status.isActive && status.levelInt > 0) {
return status.levelInt.clamp(1, 5).toInt();
}
final vipResource = profileManager.currentUserProfile?.getVIP();
return _parseVipLevel(vipResource?.name) ??
_parseVipLevel(vipResource?.code) ??
0;
}
int? _parseVipLevel(String? value) {
final text = value?.trim();
if (text == null || text.isEmpty) {
return null;
}
final vipMatch = RegExp(
r'VIP\s*([1-5])',
caseSensitive: false,
).firstMatch(text);
if (vipMatch != null) {
return int.tryParse(vipMatch.group(1) ?? '');
}
final trailingLevelMatch = RegExp(r'([1-5])$').firstMatch(text);
if (trailingLevelMatch != null) {
return int.tryParse(trailingLevelMatch.group(1) ?? '');
}
return null;
}
Widget _buildVipLevelMark(int level) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'sc_images/vip/sc_vip_letter_v.png',
width: 18.1.w,
height: 15.3.w,
fit: BoxFit.contain,
),
Image.asset(
'sc_images/vip/sc_vip_letter_i.png',
width: 8.3.w,
height: 15.3.w,
fit: BoxFit.contain,
),
Image.asset(
'sc_images/vip/sc_vip_letter_p.png',
width: 18.1.w,
height: 15.3.w,
fit: BoxFit.contain,
),
SizedBox(width: 3.w),
_buildVipLevelNumber(level),
],
);
}
Widget _buildVipLevelNumber(int level) {
return Image.asset(
'sc_images/vip/sc_vip_number_${level}_filled.png',
height: 15.247.w,
fit: BoxFit.fill,
color: const Color(0xFFFFD155),
colorBlendMode: BlendMode.srcIn,
);
}
Widget _buildInviteActivityBanner() {
return SCDebounceWidget(
onTap: _openInviteActivity,
@ -540,6 +798,8 @@ class _MePage2State extends State<MePage2> {
required String title,
required String iconPath,
required VoidCallback onTap,
double? iconWidth,
double? iconHeight,
}) {
return Expanded(
child: SCDebounceWidget(
@ -555,10 +815,17 @@ class _MePage2State extends State<MePage2> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(iconPath, width: 54.w, height: 54.w),
Image.asset(
iconPath,
width: (iconWidth ?? 54).w,
height: (iconHeight ?? 54).w,
fit: BoxFit.contain,
),
SizedBox(height: 4.w),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,

View File

@ -0,0 +1,788 @@
import 'dart:async';
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/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
class TaskPage extends StatefulWidget {
const TaskPage({super.key});
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
static const String _taskMicAsset = 'sc_images/index/sc_icon_task_mic.png';
static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png';
static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
final SCAccountRepository _repository = SCAccountRepository();
final Set<num> _claimedTaskIds = {};
final Set<num> _claimingTaskIds = {};
List<SCTaskListRes> _tasks = [];
bool _loading = true;
String? _toastMessage;
Timer? _toastTimer;
@override
void initState() {
super.initState();
_loadTasks();
}
@override
void dispose() {
_toastTimer?.cancel();
super.dispose();
}
Future<void> _loadTasks() async {
try {
final tasks = await _repository.tasks();
if (!mounted) return;
setState(() {
_tasks =
tasks
..sort((a, b) => (a.sortOrder ?? 0).compareTo(b.sortOrder ?? 0));
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_tasks = [];
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final dailyTasks = _sectionTasks(isNewcomer: false);
final newcomerTasks = _sectionTasks(isNewcomer: true);
return Scaffold(
backgroundColor: const Color(0xFF00221D),
body: SafeArea(
bottom: false,
child: Stack(
children: [
Column(
children: [
_buildTopBar(),
Expanded(
child: RefreshIndicator(
color: const Color(0xFF16DBB2),
backgroundColor: const Color(0xFF06342A),
onRefresh: _loadTasks,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(10.w, 18.w, 10.w, 28.w),
child: Column(
children: [
if (_loading)
Padding(
padding: EdgeInsets.only(top: 120.w),
child: SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF16DBB2),
),
),
)
else
..._buildTaskSections(dailyTasks, newcomerTasks),
],
),
),
),
),
],
),
if (_toastMessage != null) _buildToast(),
],
),
),
);
}
Widget _buildTopBar() {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => SCNavigatorUtils.goBack(context),
child: SizedBox(
width: 44.w,
height: 44.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 28.w,
),
),
),
),
Text(
SCAppLocalizations.of(context)!.task,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildToast() {
return Positioned(
left: 50.w,
right: 50.w,
top: 316.w,
child: IgnorePointer(
child: Container(
height: 56.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF063F36),
borderRadius: BorderRadius.circular(12.w),
border: Border.all(color: const Color(0xFF18DDB5), width: 1.w),
),
child: Text(
_toastMessage!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w400,
),
),
),
),
);
}
List<Widget> _buildTaskSections(
List<_TaskUiItem> dailyTasks,
List<_TaskUiItem> newcomerTasks,
) {
final sections = <Widget>[];
final l10n = SCAppLocalizations.of(context)!;
if (dailyTasks.isNotEmpty) {
sections.add(
_TaskSection(
title: l10n.dailyTasks,
subtitle: l10n.resetsDailyAtMidnight,
tasks: dailyTasks,
onAction: _handleTaskAction,
isClaiming: _claimingTaskIds.contains,
),
);
}
if (newcomerTasks.isNotEmpty) {
if (sections.isNotEmpty) sections.add(SizedBox(height: 14.w));
sections.add(
_TaskSection(
title: l10n.exclusiveForNewcomers,
subtitle: l10n.limitedToOneTime,
tasks: newcomerTasks,
onAction: _handleTaskAction,
isClaiming: _claimingTaskIds.contains,
),
);
}
return sections;
}
List<_TaskUiItem> _sectionTasks({required bool isNewcomer}) {
final source =
_tasks.where((task) {
final taskType = (task.taskType ?? 0).toInt();
return isNewcomer ? taskType != 0 : taskType == 0;
}).toList();
final mapped = source.map(_mapTask).toList();
if (_tasks.isNotEmpty) return mapped;
final demoTasks = _demoTasks(isNewcomer: isNewcomer);
return demoTasks
.map(
(task) =>
_claimedTaskIds.contains(task.id)
? task.copyWith(status: _TaskActionStatus.claimed)
: task,
)
.toList();
}
_TaskUiItem _mapTask(SCTaskListRes task) {
final id = task.taskId ?? -1;
final currentValue = _parseNumber(task.completedValue) ?? 0;
final targetValue =
_parseNumber(task.targetValue) ??
_parseNumber(task.conditionValue) ??
1000000;
final claimed =
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
final completedByValue = targetValue > 0 && currentValue >= targetValue;
final completedByStatus = (task.taskStatus ?? 0) == 1;
final status =
claimed
? _TaskActionStatus.claimed
: (completedByStatus || completedByValue)
? _TaskActionStatus.claim
: _TaskActionStatus.go;
return _TaskUiItem(
id: id,
title:
_nonEmpty(task.taskName) ??
SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
progressText:
'${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}',
rewardText: _formatCompactNumber(task.quantity ?? 1100),
status: status,
iconAsset: _iconForTask(task),
jumpPage: _nonEmpty(task.jumpPage),
fromApi: task.taskId != null,
);
}
Future<void> _handleTaskAction(_TaskUiItem task) async {
if (task.status == _TaskActionStatus.claimed) return;
if (task.status == _TaskActionStatus.go) {
_openJumpPage(task.jumpPage);
return;
}
if (_claimingTaskIds.contains(task.id)) return;
setState(() => _claimingTaskIds.add(task.id));
var success = true;
if (task.fromApi && task.id >= 0) {
try {
success = await _repository.taskReward(task.id);
} catch (_) {
success = false;
}
}
if (!mounted) return;
setState(() {
_claimingTaskIds.remove(task.id);
if (success) _claimedTaskIds.add(task.id);
});
if (success) {
_showToast(SCAppLocalizations.of(context)!.rewardClaimedSuccessfully);
unawaited(_loadTasks());
} else {
_showToast(SCAppLocalizations.of(context)!.rewardClaimFailed);
}
}
void _openJumpPage(String? jumpPage) {
final target = jumpPage?.trim() ?? '';
if (target.isEmpty) return;
if (target.startsWith('http://') || target.startsWith('https://')) {
SCNavigatorUtils.push(
context,
'${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(target)}&showTitle=false',
);
return;
}
if (target.startsWith('/')) {
SCNavigatorUtils.push(context, target);
}
}
void _showToast(String message) {
_toastTimer?.cancel();
setState(() => _toastMessage = message);
_toastTimer = Timer(const Duration(milliseconds: 1600), () {
if (mounted) setState(() => _toastMessage = null);
});
}
String _iconForTask(SCTaskListRes task) {
final text =
'${task.conditionType ?? ''} ${task.taskName ?? ''} ${task.taskDesc ?? ''}'
.toLowerCase();
if (text.contains('gift')) return _taskGiftAsset;
if (text.contains('game')) return _taskGameAsset;
return _taskMicAsset;
}
static num? _parseNumber(Object? value) {
if (value is num) return value;
if (value is String) return num.tryParse(value);
return null;
}
static String? _nonEmpty(String? value) {
final text = value?.trim();
return text == null || text.isEmpty ? null : text;
}
static String _formatPlainNumber(num value) {
if (value % 1 == 0) return value.toInt().toString();
return value.toStringAsFixed(1);
}
static String _formatCompactNumber(num value) {
final absValue = value.abs();
if (absValue >= 1000000) {
return '${_trimTrailingZero(value / 1000000)}m';
}
if (absValue >= 1000) {
return '${_trimTrailingZero(value / 1000)}k';
}
return _formatPlainNumber(value);
}
static String _trimTrailingZero(num value) {
final text = value.toStringAsFixed(1);
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
}
List<_TaskUiItem> _demoTasks({required bool isNewcomer}) {
final title = SCAppLocalizations.of(context)!.useMicrophoneForOneMin;
final baseId = isNewcomer ? -200 : -100;
return [
_TaskUiItem(
id: baseId - 1,
title: title,
progressText: '0/1000000',
rewardText: '1.1k',
status: _TaskActionStatus.go,
iconAsset: _taskMicAsset,
),
_TaskUiItem(
id: baseId - 2,
title: title,
progressText: '0/1000000',
rewardText: '1.1k',
status: _TaskActionStatus.claim,
iconAsset: _taskGameAsset,
),
_TaskUiItem(
id: baseId - 3,
title: title,
progressText: '0/1000000',
rewardText: '1.1k',
status: _TaskActionStatus.claimed,
iconAsset: _taskGiftAsset,
),
];
}
}
class _TaskSection extends StatelessWidget {
const _TaskSection({
required this.title,
required this.subtitle,
required this.tasks,
required this.onAction,
required this.isClaiming,
});
final String title;
final String subtitle;
final List<_TaskUiItem> tasks;
final ValueChanged<_TaskUiItem> onAction;
final bool Function(num id) isClaiming;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF052E25),
borderRadius: BorderRadius.circular(7.w),
border: Border.all(color: const Color(0xFFB4FCCE), width: 1.w),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(12.w, 24.w, 12.w, 12.w),
child: Row(
children: [
Expanded(child: _GradientTaskTitle(title)),
SizedBox(width: 8.w),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
),
for (int i = 0; i < tasks.length; i++) ...[
_TaskRow(
task: tasks[i],
onAction: onAction,
claiming: isClaiming(tasks[i].id),
),
if (i != tasks.length - 1)
Padding(
padding: EdgeInsetsDirectional.only(start: 62.w),
child: Divider(
height: 1.w,
thickness: 0.5.w,
color: const Color(0xFF295346).withValues(alpha: 0.8),
),
),
],
],
),
);
}
}
class _GradientTaskTitle extends StatelessWidget {
const _GradientTaskTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
final style = TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w900,
height: 1,
);
return LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
height: 23.w,
child: Stack(
clipBehavior: Clip.none,
children: [
PositionedDirectional(
top: 3.w,
start: 0,
end: 0,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: const Color(0xFF157D10)),
),
),
PositionedDirectional(
start: 0,
end: 0,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(
foreground:
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.w
..color = const Color(0xFF157D10),
),
),
),
PositionedDirectional(
start: 0,
end: 0,
child: ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback:
(bounds) => const LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
Color(0xFFFFFFFF),
Color(0xFFBEFAE8),
Color(0xFFFFFFFF),
],
stops: [0.224, 0.4907, 0.7224],
).createShader(bounds),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: Colors.white),
),
),
),
],
),
);
},
);
}
}
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
required this.onAction,
required this.claiming,
});
final _TaskUiItem task;
final ValueChanged<_TaskUiItem> onAction;
final bool claiming;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 65.w,
child: Row(
children: [
SizedBox(width: 12.w),
Container(
width: 42.w,
height: 42.w,
decoration: const BoxDecoration(
color: Color(0x30BEF9E8),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Image.asset(
task.iconAsset,
width: 29.4.w,
height: 29.4.w,
fit: BoxFit.contain,
),
),
SizedBox(width: 20.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1.15,
),
),
SizedBox(height: 3.w),
Text(
task.progressText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
SizedBox(height: 6.w),
Row(
children: [
Image.asset(
'sc_images/general/sc_icon_jb.png',
width: 12.w,
height: 12.w,
),
SizedBox(width: 5.w),
Text(
task.rewardText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFFFFD155),
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
],
),
),
SizedBox(width: 10.w),
_TaskActionButton(
status: task.status,
claiming: claiming,
onTap: () => onAction(task),
),
SizedBox(width: 12.w),
],
),
);
}
}
class _TaskActionButton extends StatelessWidget {
const _TaskActionButton({
required this.status,
required this.claiming,
required this.onTap,
});
final _TaskActionStatus status;
final bool claiming;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final config = _TaskButtonConfig.fromStatus(
status,
SCAppLocalizations.of(context)!,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: claiming ? null : onTap,
child: Container(
width: config.width.w,
height: 20.w,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.w),
border:
config.borderColor == null
? null
: Border.all(color: config.borderColor!, width: 1.w),
gradient: config.gradient,
color: config.gradient == null ? config.backgroundColor : null,
),
child:
claiming
? SizedBox(
width: 10.w,
height: 10.w,
child: CircularProgressIndicator(
strokeWidth: 1.5.w,
color: config.textColor,
),
)
: Text(
config.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: config.textColor,
fontSize: 13.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
),
);
}
}
class _TaskButtonConfig {
const _TaskButtonConfig({
required this.label,
required this.textColor,
required this.width,
this.backgroundColor,
this.borderColor,
this.gradient,
});
final String label;
final Color textColor;
final double width;
final Color? backgroundColor;
final Color? borderColor;
final Gradient? gradient;
factory _TaskButtonConfig.fromStatus(
_TaskActionStatus status,
SCAppLocalizations l10n,
) {
switch (status) {
case _TaskActionStatus.claim:
return _TaskButtonConfig(
label: l10n.claim,
textColor: Color(0xFF2B1500),
width: 53,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFD981), Color(0xFFFF9E2D)],
),
);
case _TaskActionStatus.claimed:
return _TaskButtonConfig(
label: l10n.claimed,
textColor: Colors.white,
width: 58,
backgroundColor: const Color(0xFF062E28),
borderColor: Colors.white.withValues(alpha: 0.56),
);
case _TaskActionStatus.go:
return _TaskButtonConfig(
label: l10n.go,
textColor: Colors.white,
width: 53,
backgroundColor: const Color(0xFF062E28),
borderColor: const Color(0xFF98E4C2),
);
}
}
}
class _TaskUiItem {
const _TaskUiItem({
required this.id,
required this.title,
required this.progressText,
required this.rewardText,
required this.status,
required this.iconAsset,
this.jumpPage,
this.fromApi = false,
});
final num id;
final String title;
final String progressText;
final String rewardText;
final _TaskActionStatus status;
final String iconAsset;
final String? jumpPage;
final bool fromApi;
_TaskUiItem copyWith({_TaskActionStatus? status}) {
return _TaskUiItem(
id: id,
title: title,
progressText: progressText,
rewardText: rewardText,
status: status ?? this.status,
iconAsset: iconAsset,
jumpPage: jumpPage,
fromApi: fromApi,
);
}
}
enum _TaskActionStatus { go, claim, claimed }

View File

@ -0,0 +1,15 @@
import 'package:fluro/fluro.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
import 'package:yumi/modules/user/task/task_page.dart';
class TaskRoute implements SCIRouterProvider {
static String task = '/main/me/task';
@override
void initRouter(FluroRouter router) {
router.define(
task,
handler: Handler(handlerFunc: (_, params) => const TaskPage()),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -889,6 +889,28 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
///
void seedSystemRoomTips(String tips, {String groupId = ""}) {
final normalizedTips = tips.trim();
if (normalizedTips.isEmpty) {
return;
}
for (final msg in roomAllMsgList) {
if (msg.type == SCRoomMsgType.systemTips && msg.msg == normalizedTips) {
if ((msg.groupId ?? "").isEmpty && groupId.isNotEmpty) {
msg.groupId = groupId;
}
return;
}
}
addMsg(
Msg(
groupId: groupId,
msg: normalizedTips,
type: SCRoomMsgType.systemTips,
),
);
}
addMsg(Msg msg) {
final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg);
if (mergedGiftMsg != null) {

View File

@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
class SCHomeRoomPreloadManager {
SCHomeRoomPreloadManager._();
static final SCHomeRoomPreloadManager instance = SCHomeRoomPreloadManager._();
static const Duration _partyRoomCacheTtl = Duration(seconds: 30);
Future<List<SocialChatRoomRes>>? _partyRoomsFuture;
List<SocialChatRoomRes>? _partyRooms;
DateTime? _partyRoomsLoadedAt;
void preloadInitialHomeRooms() {
final token = AccountStorage().getToken();
if (AccountStorage().getCurrentUser() == null || token.isEmpty) {
return;
}
unawaited(
loadPartyRooms().catchError((error, stackTrace) {
debugPrint('[HomePreload] party rooms preload failed: $error');
return <SocialChatRoomRes>[];
}),
);
}
Future<List<SocialChatRoomRes>> loadPartyRooms({bool forceRefresh = false}) {
if (!forceRefresh && _hasFreshPartyRoomCache) {
return Future.value(List<SocialChatRoomRes>.from(_partyRooms!));
}
if (!forceRefresh) {
final pendingFuture = _partyRoomsFuture;
if (pendingFuture != null) {
return pendingFuture;
}
}
final future = SCChatRoomRepository()
.discovery(allRegion: true)
.then((rooms) {
_partyRooms = List<SocialChatRoomRes>.from(rooms);
_partyRoomsLoadedAt = DateTime.now();
return List<SocialChatRoomRes>.from(rooms);
})
.whenComplete(() {
_partyRoomsFuture = null;
});
_partyRoomsFuture = future;
return future;
}
bool get _hasFreshPartyRoomCache {
final rooms = _partyRooms;
final loadedAt = _partyRoomsLoadedAt;
if (rooms == null || loadedAt == null) {
return false;
}
return DateTime.now().difference(loadedAt) < _partyRoomCacheTtl;
}
}

View File

@ -0,0 +1,165 @@
import 'dart:io';
import 'package:on_audio_query/on_audio_query.dart';
import 'package:path/path.dart' as p;
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart';
class LocalMusicScanner {
LocalMusicScanner({OnAudioQuery? audioQuery})
: _audioQuery = audioQuery ?? OnAudioQuery();
final OnAudioQuery _audioQuery;
Future<List<SCMusicFolderMode>> scanMp3Folders({
List<SCMusicMode> addedSongs = const [],
}) async {
final hasPermission = await SCPermissionUtils.checkAudioPermission();
if (!hasPermission) {
return const <SCMusicFolderMode>[];
}
final List<SongModel> songs;
try {
songs = await _audioQuery.querySongs(
sortType: SongSortType.TITLE,
orderType: OrderType.ASC_OR_SMALLER,
uriType: UriType.EXTERNAL,
ignoreCase: true,
);
} catch (_) {
return const <SCMusicFolderMode>[];
}
final addedIds = addedSongs.map((item) => item.id).toSet();
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
final byFolder = <String, List<SCMusicMode>>{};
final seen = <String>{};
for (final song in songs) {
if (!_isMp3(song)) {
continue;
}
final localPath = _songString(song, "_data").trim();
if (localPath.isEmpty || !_fileExists(localPath)) {
continue;
}
final id = _songString(song, "_id");
final key = id.isNotEmpty ? id : localPath;
if (!seen.add(key)) {
continue;
}
final folderPath = p.dirname(localPath);
final fileName = _safeFileName(song, localPath);
final title = _safeTitle(song, fileName);
final model = SCMusicMode(
id: key,
title: title,
artist: _songString(song, "artist"),
durationMs: _songInt(song, "duration"),
localPath: localPath,
contentUri: _songString(song, "_uri"),
folderPath: folderPath,
fileName: fileName,
size: _songInt(song, "_size"),
addedAt: DateTime.now().millisecondsSinceEpoch,
);
byFolder.putIfAbsent(folderPath, () => <SCMusicMode>[]).add(model);
}
final folders =
byFolder.entries.map((entry) {
final path = entry.key;
final folderSongs =
entry.value..sort(
(a, b) =>
a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final addedCount =
folderSongs.where((song) {
return addedIds.contains(song.id) ||
addedPaths.contains(song.localPath);
}).length;
return SCMusicFolderMode(
id: path,
name: p.basename(path).isEmpty ? path : p.basename(path),
path: path,
songs: List<SCMusicMode>.unmodifiable(folderSongs),
addedCount: addedCount,
);
}).toList()
..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return List<SCMusicFolderMode>.unmodifiable(folders);
}
bool _isMp3(SongModel song) {
final extension = _songString(
song,
"file_extension",
).toLowerCase().replaceFirst(".", "");
if (extension == "mp3") {
return true;
}
return _songString(song, "_display_name").toLowerCase().endsWith(".mp3") ||
_songString(song, "_data").toLowerCase().endsWith(".mp3");
}
bool _fileExists(String path) {
try {
return File(path).existsSync();
} catch (_) {
return false;
}
}
String _safeFileName(SongModel song, String localPath) {
final displayName = _songString(song, "_display_name").trim();
if (displayName.isNotEmpty) {
return displayName;
}
return p.basename(localPath);
}
String _safeTitle(SongModel song, String fileName) {
final title = _songString(song, "title").trim();
if (title.isNotEmpty) {
return title;
}
final displayNameWOExt = _songString(song, "_display_name_wo_ext").trim();
if (displayNameWOExt.isNotEmpty) {
return displayNameWOExt;
}
return p.basenameWithoutExtension(fileName);
}
String _songString(SongModel song, String key) {
final value = _songValue(song, key);
if (value == null) {
return "";
}
return value.toString();
}
int _songInt(SongModel song, String key) {
final value = _songValue(song, key);
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "") ?? 0;
}
Object? _songValue(SongModel song, String key) {
try {
return song.getMap[key];
} catch (_) {
return null;
}
}
}

View File

@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/room_music_repository.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
enum RoomMusicPlayMode { list, shuffle, single }
class RoomMusicManager extends ChangeNotifier {
RoomMusicManager({RoomMusicRepository? repository})
: _repository = repository ?? RoomMusicRepository() {
_playlist = _repository.loadSongs();
}
final RoomMusicRepository _repository;
final Random _random = Random();
RtcProvider? _rtcProvider;
Timer? _positionTimer;
List<SCMusicMode> _playlist = <SCMusicMode>[];
SCMusicMode? _current;
RoomMusicPlayMode _playMode = RoomMusicPlayMode.list;
int _volume = 70;
int _positionMs = 0;
int _durationMs = 0;
int _lastStartAtMs = 0;
int _ignoreStoppedUntilMs = 0;
bool _isPlaying = false;
bool _isPaused = false;
bool _isStarting = false;
bool _isStoppingByUser = false;
bool _lastLoopback = true;
bool _roomPlayerVisible = false;
List<SCMusicMode> get playlist => List.unmodifiable(_playlist);
SCMusicMode? get current => _current;
RoomMusicPlayMode get playMode => _playMode;
int get volume => _volume;
int get positionMs => _positionMs;
int get durationMs =>
_durationMs > 0 ? _durationMs : (_current?.durationMs ?? 0);
bool get isPlaying => _isPlaying;
bool get isPaused => _isPaused;
bool get hasCurrent => _current != null;
bool get roomPlayerVisible => _roomPlayerVisible;
bool get isPublishingToRoom => _isPlaying && !_lastLoopback;
int get currentIndex {
final currentId = _current?.id;
if (currentId == null) {
return -1;
}
return _playlist.indexWhere((item) => item.id == currentId);
}
void attachRtcProvider(RtcProvider provider) {
if (_rtcProvider == provider) {
return;
}
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
_rtcProvider = provider;
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
}
Future<void> reload() async {
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> applyFolderSelection({
required SCMusicFolderMode folder,
required Set<String> selectedIds,
}) async {
final currentById = {for (final item in _playlist) item.id: item};
final folderIds = folder.songs.map((item) => item.id).toSet();
final next = <SCMusicMode>[
..._playlist.where((item) => !folderIds.contains(item.id)),
];
final now = DateTime.now().millisecondsSinceEpoch;
for (final song in folder.songs) {
if (!selectedIds.contains(song.id)) {
continue;
}
final existing = currentById[song.id];
next.add(
(existing ?? song).copyWith(
addedAt:
existing?.addedAt == null || existing!.addedAt <= 0
? now
: existing.addedAt,
),
);
}
await _savePlaylist(next);
}
Future<void> deleteMusic(SCMusicMode item) async {
final deletingCurrent = _current?.id == item.id;
if (deletingCurrent) {
await stop(clearCurrent: true);
}
await _savePlaylist(_playlist.where((song) => song.id != item.id).toList());
}
Future<void> reorder(int oldIndex, int newIndex) async {
if (oldIndex < 0 || oldIndex >= _playlist.length) {
return;
}
final next = [..._playlist];
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = next.removeAt(oldIndex);
next.insert(newIndex.clamp(0, next.length).toInt(), item);
await _savePlaylist(next);
}
Future<void> play(SCMusicMode item, {int startPositionMs = 0}) async {
if (item.playPath.isEmpty) {
SCTts.show("Music file not found");
return;
}
if (!item.playPath.startsWith("content://") &&
!File(item.localPath).existsSync()) {
SCTts.show("Music file not found");
await deleteMusic(item);
return;
}
_current = item;
_durationMs = item.durationMs;
_positionMs = startPositionMs;
_isPaused = false;
_roomPlayerVisible = true;
await _startCurrent(startPositionMs: startPositionMs);
}
Future<void> pause() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
await _rtcProvider?.pauseRoomMusicAudioMixing();
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
}
Future<void> resume() async {
if (_current == null || _rtcProvider == null) {
return;
}
if (_isPaused) {
await _rtcProvider?.resumeRoomMusicAudioMixing();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
return;
}
await _startCurrent(startPositionMs: _positionMs);
}
Future<void> stop({bool clearCurrent = false}) async {
_isStoppingByUser = true;
try {
await _rtcProvider?.stopRoomMusicAudioMixing();
} catch (_) {
// Ignore stop failures; local state must still be reset.
} finally {
_isStoppingByUser = false;
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
if (clearCurrent) {
_current = null;
_durationMs = 0;
_roomPlayerVisible = false;
}
notifyListeners();
}
}
Future<void> stopForRoomExit() async {
await stop(clearCurrent: true);
}
Future<void> previous() async {
if (_playlist.isEmpty) {
return;
}
final index = currentIndex;
final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
await play(_playlist[targetIndex]);
}
Future<void> next({bool fromAutoComplete = false}) async {
if (_playlist.isEmpty) {
await stop(clearCurrent: true);
return;
}
if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) {
await play(_current ?? _playlist.first);
return;
}
int targetIndex = 0;
final index = currentIndex;
if (_playMode == RoomMusicPlayMode.shuffle) {
if (_playlist.length == 1) {
targetIndex = 0;
} else {
do {
targetIndex = _random.nextInt(_playlist.length);
} while (targetIndex == index);
}
} else {
targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length;
}
await play(_playlist[targetIndex]);
}
Future<void> seek(int positionMs) async {
final safePosition = positionMs.clamp(0, durationMs).toInt();
_positionMs = safePosition;
await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
notifyListeners();
}
Future<void> setVolume(int volume) async {
_volume = volume.clamp(0, 100).toInt();
await _rtcProvider?.setRoomMusicAudioMixingVolume(_volume);
notifyListeners();
}
String switchPlayMode() {
switch (_playMode) {
case RoomMusicPlayMode.list:
_playMode = RoomMusicPlayMode.shuffle;
notifyListeners();
return "随机播放";
case RoomMusicPlayMode.shuffle:
_playMode = RoomMusicPlayMode.single;
notifyListeners();
return "单曲循环播放";
case RoomMusicPlayMode.single:
_playMode = RoomMusicPlayMode.list;
notifyListeners();
return "列表顺序播放";
}
}
void toggleRoomPlayerVisible() {
if (_current == null) {
return;
}
_roomPlayerVisible = !_roomPlayerVisible;
notifyListeners();
}
void hideRoomPlayer() {
if (!_roomPlayerVisible) {
return;
}
_roomPlayerVisible = false;
notifyListeners();
}
Future<void> syncPlaybackRouting() async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || !_isPlaying) {
return;
}
final nextLoopback = !rtcProvider.isOnMai();
if (nextLoopback == _lastLoopback) {
return;
}
final position = await rtcProvider
.getRoomMusicAudioMixingPosition()
.catchError((_) => _positionMs);
_ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900;
try {
await rtcProvider.stopRoomMusicAudioMixing();
await _startCurrent(startPositionMs: position);
} catch (_) {
_isPlaying = false;
_isPaused = false;
notifyListeners();
}
}
Future<void> _savePlaylist(List<SCMusicMode> songs) async {
await _repository.saveSongs(songs);
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> _startCurrent({required int startPositionMs}) async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || _isStarting) {
return;
}
_isStarting = true;
try {
await _waitForStartInterval();
_lastLoopback = !rtcProvider.isOnMai();
await rtcProvider.startRoomMusicAudioMixing(
filePath: current.playPath,
loopback: _lastLoopback,
cycle: 1,
startPos: startPositionMs,
);
_lastStartAtMs = DateTime.now().millisecondsSinceEpoch;
await rtcProvider.setRoomMusicAudioMixingVolume(_volume);
_positionMs = startPositionMs;
_durationMs =
current.durationMs > 0
? current.durationMs
: await rtcProvider.getRoomMusicAudioMixingDuration();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
} catch (_) {
_isPlaying = false;
_isPaused = false;
_stopPositionTimer();
SCTts.show("Music playback failed");
notifyListeners();
} finally {
_isStarting = false;
}
}
Future<void> _waitForStartInterval() async {
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _lastStartAtMs;
if (elapsed < 550) {
await Future<void>.delayed(Duration(milliseconds: 550 - elapsed));
}
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
unawaited(_refreshPosition());
});
}
void _stopPositionTimer() {
_positionTimer?.cancel();
_positionTimer = null;
}
Future<void> _refreshPosition() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
try {
final position = await _rtcProvider!.getRoomMusicAudioMixingPosition();
final duration = await _rtcProvider!.getRoomMusicAudioMixingDuration();
if (position >= 0) {
_positionMs = position;
}
if (duration > 0) {
_durationMs = duration;
}
notifyListeners();
} catch (_) {
// Position polling is best-effort only.
}
}
void _handleMixingStateChanged(
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStatePaused:
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStateStopped:
if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) {
return;
}
_stopPositionTimer();
if (reason ==
AudioMixingReasonType.audioMixingReasonAllLoopsCompleted) {
unawaited(next(fromAutoComplete: true));
return;
}
if (!_isStoppingByUser) {
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
notifyListeners();
}
break;
case AudioMixingStateType.audioMixingStateFailed:
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
SCTts.show("Music playback failed");
notifyListeners();
break;
}
}
void _handleMicRouteChanged(bool isOnMic) {
unawaited(syncPlaybackRouting());
}
void _restoreCurrentAfterPlaylistChange() {
final currentId = _current?.id;
if (currentId == null) {
return;
}
final index = _playlist.indexWhere((item) => item.id == currentId);
if (index >= 0) {
_current = _playlist[index];
return;
}
_current = null;
_isPlaying = false;
_isPaused = false;
_roomPlayerVisible = false;
_positionMs = 0;
_durationMs = 0;
_stopPositionTimer();
}
@override
void dispose() {
_positionTimer?.cancel();
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
super.dispose();
}
}

View File

@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
class RoomMusicRepository {
List<SCMusicMode> loadSongs() {
final raw = DataPersistence.getUserRoomMusic();
if (raw.trim().isEmpty) {
return <SCMusicMode>[];
}
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <SCMusicMode>[];
}
final songs =
decoded
.map((item) => SCMusicMode.fromJson(item))
.where((item) => item.hasPlayablePath)
.toList()
..sort((a, b) {
final sort = a.sortIndex.compareTo(b.sortIndex);
if (sort != 0) {
return sort;
}
return a.addedAt.compareTo(b.addedAt);
});
return _normalizeSort(songs);
} catch (_) {
return <SCMusicMode>[];
}
}
Future<bool> saveSongs(List<SCMusicMode> songs) {
final normalized = _normalizeSort(_dedupeSongs(songs));
return DataPersistence.setUserRoomMusic(
jsonEncode(normalized.map((item) => item.toJson()).toList()),
);
}
List<SCMusicMode> _dedupeSongs(List<SCMusicMode> songs) {
final seen = <String>{};
final result = <SCMusicMode>[];
for (final song in songs) {
final key = song.id.isNotEmpty ? song.id : song.localPath;
if (key.isEmpty || !seen.add(key)) {
continue;
}
result.add(song);
}
return result;
}
List<SCMusicMode> _normalizeSort(List<SCMusicMode> songs) {
return List<SCMusicMode>.generate(
songs.length,
(index) => songs[index].copyWith(sortIndex: index),
growable: true,
);
}
}

View File

@ -9,11 +9,12 @@ import 'package:yumi/shared/business_logic/models/res/sc_edit_room_info_res.dart
import 'package:yumi/shared/business_logic/models/res/my_room_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_contribute_level_res.dart';
class SocialChatRoomManager extends ChangeNotifier {
MyRoomRes? myRoom;
SCRoomContributeLevelRes? roomContributeLevelRes;
bool isMyRoomLoading = false;
bool hasLoadedMyRoom = false;
class SocialChatRoomManager extends ChangeNotifier {
MyRoomRes? myRoom;
SCRoomContributeLevelRes? roomContributeLevelRes;
String? _roomContributeLevelRoomId;
bool isMyRoomLoading = false;
bool hasLoadedMyRoom = false;
Future<void> fetchMyRoomData() async {
if (isMyRoomLoading) {
@ -73,13 +74,41 @@ class SocialChatRoomManager extends ChangeNotifier {
SCLoadingManager.hide();
}
void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) {
if (forceRefresh) {
roomContributeLevelRes = null;
}
SCChatRoomRepository().roomContributionActivity(roomId).then((value) {
roomContributeLevelRes = value;
notifyListeners();
});
}
}
void clearContributionLevelData({bool notify = true}) {
_roomContributeLevelRoomId = null;
if (roomContributeLevelRes == null) {
return;
}
roomContributeLevelRes = null;
if (notify) {
notifyListeners();
}
}
void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) {
final resolvedRoomId = roomId.trim();
if (resolvedRoomId.isEmpty) {
clearContributionLevelData();
return;
}
final shouldClear =
forceRefresh || _roomContributeLevelRoomId != resolvedRoomId;
_roomContributeLevelRoomId = resolvedRoomId;
if (shouldClear && roomContributeLevelRes != null) {
roomContributeLevelRes = null;
notifyListeners();
}
SCChatRoomRepository()
.roomContributionActivity(resolvedRoomId)
.then((value) {
if (_roomContributeLevelRoomId != resolvedRoomId) {
return;
}
roomContributeLevelRes = value;
notifyListeners();
})
.catchError((_) {});
}
}

View File

@ -505,7 +505,13 @@ class SocialChatUserProfile {
}
PropsResources? getVIP() {
return null;
PropsResources? pr;
_useProps?.forEach((value) {
if (value.propsResources?.type == SCPropsType.NOBLE_VIP.name) {
pr = value.propsResources;
}
});
return pr;
}
PropsResources? getDataCard() {

View File

@ -0,0 +1,743 @@
class SCVipResourceRes {
SCVipResourceRes({
String? resourceId,
String? name,
String? url,
String? sourceUrl,
String? resourceUrl,
String? cover,
String? coverUrl,
}) {
_resourceId = resourceId;
_name = name;
_url = url;
_sourceUrl = sourceUrl;
_resourceUrl = resourceUrl;
_cover = cover;
_coverUrl = coverUrl;
}
SCVipResourceRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_resourceId = _stringValue(map['resourceId']);
_name = _stringValue(map['name']);
_url = _stringValue(map['url']);
_sourceUrl = _stringValue(map['sourceUrl']);
_resourceUrl = _stringValue(map['resourceUrl']);
_cover = _stringValue(map['cover']);
_coverUrl = _stringValue(map['coverUrl']);
}
String? _resourceId;
String? _name;
String? _url;
String? _sourceUrl;
String? _resourceUrl;
String? _cover;
String? _coverUrl;
String? get resourceId => _resourceId;
String? get name => _name;
String? get url => _url;
String? get sourceUrl => _sourceUrl;
String? get resourceUrl => _resourceUrl;
String? get cover => _cover;
String? get coverUrl => _coverUrl;
String? get previewUrl {
final cover = _firstNonBlank([_coverUrl, _cover]);
if (cover != null) return cover;
final staticUrl = _firstNonBlank(
[_url, _resourceUrl, _sourceUrl].where((value) {
final text = value?.trim();
return text != null && text.isNotEmpty && !_isSvgaUrl(text);
}),
);
if (staticUrl != null) return staticUrl;
return _firstNonBlank([_url, _resourceUrl, _sourceUrl]);
}
String? get sourceResourceUrl =>
_firstNonBlank([_sourceUrl, _resourceUrl, _url]);
bool get hasIdentity {
final id = _resourceId?.trim();
return id != null && id.isNotEmpty && id != '0';
}
bool get hasAnyUrl =>
_firstNonBlank([_url, _sourceUrl, _resourceUrl, _cover, _coverUrl]) !=
null;
bool get hasPreviewUrl => previewUrl != null;
bool get hasSourceUrl => sourceResourceUrl != null;
bool get hasContent =>
hasAnyUrl || hasIdentity || (_name?.trim().isNotEmpty ?? false);
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['resourceId'] = _resourceId;
map['name'] = _name;
map['url'] = _url;
map['sourceUrl'] = _sourceUrl;
map['resourceUrl'] = _resourceUrl;
map['cover'] = _cover;
map['coverUrl'] = _coverUrl;
return map;
}
}
class SCVipStatusRes {
SCVipStatusRes({
String? userId,
String? sysOrigin,
bool? active,
String? status,
num? level,
String? levelCode,
String? displayName,
String? startAt,
String? expireAt,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_active = active;
_status = status;
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_startAt = startAt;
_expireAt = expireAt;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
}
SCVipStatusRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_active = _boolValue(map['active']);
_status = _stringValue(map['status']);
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
}
String? _userId;
String? _sysOrigin;
bool? _active;
String? _status;
num? _level;
String? _levelCode;
String? _displayName;
String? _startAt;
String? _expireAt;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get active => _active;
String? get status => _status;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool get isActive => _active == true && (_level ?? 0) > 0;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP $levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['active'] = _active;
map['status'] = _status;
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
return map;
}
}
class SCVipLevelConfigRes {
SCVipLevelConfigRes({
num? level,
String? levelCode,
String? displayName,
bool? enabled,
num? durationDays,
num? priceGold,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
bool? canPurchase,
num? payableGold,
}) {
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_enabled = enabled;
_durationDays = durationDays;
_priceGold = priceGold;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
_canPurchase = canPurchase;
_payableGold = payableGold;
}
SCVipLevelConfigRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_enabled = _boolValue(map['enabled']);
_durationDays = _numValue(map['durationDays']);
_priceGold = _numValue(map['priceGold']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
_canPurchase = _boolValue(map['canPurchase']);
_payableGold = _numValue(map['payableGold']);
}
num? _level;
String? _levelCode;
String? _displayName;
bool? _enabled;
num? _durationDays;
num? _priceGold;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
bool? _canPurchase;
num? _payableGold;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
bool? get enabled => _enabled;
num? get durationDays => _durationDays;
num? get priceGold => _priceGold;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool? get canPurchase => _canPurchase;
num? get payableGold => _payableGold;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP$levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['enabled'] = _enabled;
map['durationDays'] = _durationDays;
map['priceGold'] = _priceGold;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
map['canPurchase'] = _canPurchase;
map['payableGold'] = _payableGold;
return map;
}
}
class SCVipHomeRes {
SCVipHomeRes({
String? userId,
String? sysOrigin,
bool? configured,
SCVipStatusRes? state,
List<SCVipLevelConfigRes>? levels,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_configured = configured;
_state = state;
_levels = levels;
}
SCVipHomeRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_configured = _boolValue(map['configured']);
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
_levels =
_asList(
map['levels'],
).map((dynamic item) => SCVipLevelConfigRes.fromJson(item)).toList();
}
String? _userId;
String? _sysOrigin;
bool? _configured;
SCVipStatusRes? _state;
List<SCVipLevelConfigRes>? _levels;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get configured => _configured;
SCVipStatusRes? get state => _state;
List<SCVipLevelConfigRes> get levels => _levels ?? <SCVipLevelConfigRes>[];
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['configured'] = _configured;
if (_state != null) map['state'] = _state?.toJson();
map['levels'] = levels.map((item) => item.toJson()).toList();
return map;
}
}
class SCVipPreviewRes {
SCVipPreviewRes({
String? userId,
String? sysOrigin,
String? orderType,
num? currentLevel,
num? targetLevel,
num? durationDays,
num? originalPriceGold,
num? payableGold,
String? startAt,
String? expireAt,
SCVipLevelConfigRes? targetConfig,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_orderType = orderType;
_currentLevel = currentLevel;
_targetLevel = targetLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_payableGold = payableGold;
_startAt = startAt;
_expireAt = expireAt;
_targetConfig = targetConfig;
}
SCVipPreviewRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_orderType = _stringValue(map['orderType']);
_currentLevel = _numValue(map['currentLevel']);
_targetLevel = _numValue(map['targetLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_payableGold = _numValue(map['payableGold']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
final targetConfigMap = _asMap(map['targetConfig']);
_targetConfig =
targetConfigMap != null && targetConfigMap.isNotEmpty
? SCVipLevelConfigRes.fromJson(targetConfigMap)
: null;
}
String? _userId;
String? _sysOrigin;
String? _orderType;
num? _currentLevel;
num? _targetLevel;
num? _durationDays;
num? _originalPriceGold;
num? _payableGold;
String? _startAt;
String? _expireAt;
SCVipLevelConfigRes? _targetConfig;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
String? get orderType => _orderType;
num? get currentLevel => _currentLevel;
num? get targetLevel => _targetLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get payableGold => _payableGold;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipLevelConfigRes? get targetConfig => _targetConfig;
int get targetLevelInt => (_targetLevel ?? 0).toInt();
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['orderType'] = _orderType;
map['currentLevel'] = _currentLevel;
map['targetLevel'] = _targetLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['payableGold'] = _payableGold;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_targetConfig != null) {
map['targetConfig'] = _targetConfig?.toJson();
}
return map;
}
}
class SCVipPurchaseRes {
SCVipPurchaseRes({
bool? success,
SCVipOrderRes? order,
SCVipStatusRes? state,
}) {
_success = success;
_order = order;
_state = state;
}
SCVipPurchaseRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_success = _boolValue(map['success']);
final orderMap = _asMap(map['order']);
_order =
orderMap != null && orderMap.isNotEmpty
? SCVipOrderRes.fromJson(orderMap)
: null;
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
}
bool? _success;
SCVipOrderRes? _order;
SCVipStatusRes? _state;
bool? get success => _success;
SCVipOrderRes? get order => _order;
SCVipStatusRes? get state => _state;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['success'] = _success;
if (_order != null) map['order'] = _order?.toJson();
if (_state != null) map['state'] = _state?.toJson();
return map;
}
}
class SCVipOrdersRes {
SCVipOrdersRes({List<SCVipOrderRes>? records, num? total}) {
_records = records;
_total = total;
}
SCVipOrdersRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_records =
_asList(
map['records'],
).map((dynamic item) => SCVipOrderRes.fromJson(item)).toList();
_total = _numValue(map['total']);
}
List<SCVipOrderRes>? _records;
num? _total;
List<SCVipOrderRes> get records => _records ?? <SCVipOrderRes>[];
num? get total => _total;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['records'] = records.map((item) => item.toJson()).toList();
map['total'] = _total;
return map;
}
}
class SCVipOrderRes {
SCVipOrderRes({
String? id,
String? eventId,
String? sysOrigin,
String? userId,
String? orderType,
num? fromLevel,
num? toLevel,
num? durationDays,
num? originalPriceGold,
num? paidGold,
String? status,
String? startAt,
String? expireAt,
}) {
_id = id;
_eventId = eventId;
_sysOrigin = sysOrigin;
_userId = userId;
_orderType = orderType;
_fromLevel = fromLevel;
_toLevel = toLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_paidGold = paidGold;
_status = status;
_startAt = startAt;
_expireAt = expireAt;
}
SCVipOrderRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_id = _stringValue(map['id']);
_eventId = _stringValue(map['eventId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_userId = _stringValue(map['userId']);
_orderType = _stringValue(map['orderType']);
_fromLevel = _numValue(map['fromLevel']);
_toLevel = _numValue(map['toLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_paidGold = _numValue(map['paidGold']);
_status = _stringValue(map['status']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
}
String? _id;
String? _eventId;
String? _sysOrigin;
String? _userId;
String? _orderType;
num? _fromLevel;
num? _toLevel;
num? _durationDays;
num? _originalPriceGold;
num? _paidGold;
String? _status;
String? _startAt;
String? _expireAt;
String? get id => _id;
String? get eventId => _eventId;
String? get sysOrigin => _sysOrigin;
String? get userId => _userId;
String? get orderType => _orderType;
num? get fromLevel => _fromLevel;
num? get toLevel => _toLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get paidGold => _paidGold;
String? get status => _status;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['eventId'] = _eventId;
map['sysOrigin'] = _sysOrigin;
map['userId'] = _userId;
map['orderType'] = _orderType;
map['fromLevel'] = _fromLevel;
map['toLevel'] = _toLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['paidGold'] = _paidGold;
map['status'] = _status;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
return map;
}
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
List<dynamic> _asList(dynamic value) {
if (value is List) return value;
return <dynamic>[];
}
SCVipResourceRes? _resourceValue(dynamic value) {
final map = _asMap(value);
if (map == null || map.isEmpty) return null;
return SCVipResourceRes.fromJson(map);
}
String? _stringValue(dynamic value) {
if (value == null) return null;
return value.toString();
}
num? _numValue(dynamic value) {
if (value == null) return null;
if (value is num) return value;
return num.tryParse(value.toString());
}
bool? _boolValue(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value != 0;
final lower = value.toString().toLowerCase();
if (lower == 'true') return true;
if (lower == 'false') return false;
return null;
}
String? _firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim();
if (text != null && text.isNotEmpty) {
return text;
}
}
return null;
}
bool _isSvgaUrl(String value) {
final lower = value.toLowerCase();
return lower.endsWith('.svga') || lower.contains('.svga?');
}

View File

@ -0,0 +1,19 @@
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
abstract class SocialChatVipRepository {
Future<SCVipStatusRes> vipStatus();
Future<SCVipHomeRes> vipHome();
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
});
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
});
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20});
}

View File

@ -0,0 +1,29 @@
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class SCMusicFolderMode {
const SCMusicFolderMode({
required this.id,
required this.name,
required this.path,
required this.songs,
this.addedCount = 0,
});
final String id;
final String name;
final String path;
final List<SCMusicMode> songs;
final int addedCount;
int get musicCount => songs.length;
SCMusicFolderMode copyWith({List<SCMusicMode>? songs, int? addedCount}) {
return SCMusicFolderMode(
id: id,
name: name,
path: path,
songs: songs ?? this.songs,
addedCount: addedCount ?? this.addedCount,
);
}
}

View File

@ -1,37 +1,149 @@
class SCMusicMode {
SCMusicMode({String? title, String? artist,int? duration,String? localPath}) {
_title = title;
_artist = artist;
_duration = duration;
_localPath = localPath;
}
String? _title;
String? _artist;
int? _duration;
String? _localPath;
String? get title => _title;
String? get artist => _artist;
int? get duration => _duration;
String? get localPath => _localPath;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _duration;
map['localPath'] = _localPath;
return map;
}
SCMusicMode.fromJson(dynamic json) {
_title = json['title'];
_artist = json['artist'];
_duration = json['duration'];
_localPath = json['localPath'];
}
}
import 'dart:convert';
class SCMusicMode {
SCMusicMode({
String? id,
String? title,
String? artist,
int? duration,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
_id = id;
_title = title;
_artist = artist;
_durationMs = durationMs ?? duration;
_localPath = localPath;
_contentUri = contentUri;
_folderPath = folderPath;
_fileName = fileName;
_size = size;
_sortIndex = sortIndex;
_addedAt = addedAt;
}
String? _id;
String? _title;
String? _artist;
int? _durationMs;
String? _localPath;
String? _contentUri;
String? _folderPath;
String? _fileName;
int? _size;
int? _sortIndex;
int? _addedAt;
String get id {
final rawId = (_id ?? "").trim();
if (rawId.isNotEmpty) {
return rawId;
}
final source = playPath.isNotEmpty ? playPath : title;
return base64Url.encode(utf8.encode(source)).replaceAll('=', '');
}
String get title => (_title ?? _fileName ?? "").trim();
String get artist => (_artist ?? "").trim();
int get durationMs => _durationMs ?? 0;
int get duration => durationMs;
String get localPath => (_localPath ?? "").trim();
String get contentUri => (_contentUri ?? "").trim();
String get folderPath => (_folderPath ?? "").trim();
String get fileName => (_fileName ?? title).trim();
int get size => _size ?? 0;
int get sortIndex => _sortIndex ?? 0;
int get addedAt => _addedAt ?? 0;
String get playPath => contentUri.isNotEmpty ? contentUri : localPath;
bool get hasPlayablePath => playPath.isNotEmpty;
SCMusicMode copyWith({
String? id,
String? title,
String? artist,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
return SCMusicMode(
id: id ?? _id,
title: title ?? _title,
artist: artist ?? _artist,
durationMs: durationMs ?? _durationMs,
localPath: localPath ?? _localPath,
contentUri: contentUri ?? _contentUri,
folderPath: folderPath ?? _folderPath,
fileName: fileName ?? _fileName,
size: size ?? _size,
sortIndex: sortIndex ?? _sortIndex,
addedAt: addedAt ?? _addedAt,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _durationMs;
map['durationMs'] = _durationMs;
map['localPath'] = _localPath;
map['contentUri'] = _contentUri;
map['folderPath'] = _folderPath;
map['fileName'] = _fileName;
map['size'] = _size;
map['sortIndex'] = _sortIndex;
map['addedAt'] = _addedAt;
return map;
}
SCMusicMode.fromJson(dynamic json) {
if (json is! Map) {
return;
}
_id = json['id']?.toString();
_title = json['title']?.toString();
_artist = json['artist']?.toString();
_durationMs = _asInt(json['durationMs'] ?? json['duration']);
_localPath = json['localPath']?.toString();
_contentUri = json['contentUri']?.toString();
_folderPath = json['folderPath']?.toString();
_fileName = json['fileName']?.toString();
_size = _asInt(json['size']);
_sortIndex = _asInt(json['sortIndex']);
_addedAt = _asInt(json['addedAt']);
}
static int? _asInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "");
}
}

View File

@ -0,0 +1,83 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class DurableAuthStorage {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/durable_auth_storage',
);
static const String _sessionKey = 'auth_session_v1';
static Future<void> saveSession({
required String token,
required String currentUserJson,
}) async {
if (!Platform.isIOS) {
return;
}
if (token.trim().isEmpty || currentUserJson.trim().isEmpty) {
await clearSession();
return;
}
await _invokeSafely('write', <String, Object>{
'key': _sessionKey,
'value': jsonEncode(<String, String>{
'token': token,
'currentUser': currentUserJson,
}),
});
}
static Future<DurableAuthSession?> readSession() async {
if (!Platform.isIOS) {
return null;
}
try {
final raw = await _channel.invokeMethod<String>('read', <String, Object>{
'key': _sessionKey,
});
if (raw == null || raw.trim().isEmpty) {
return null;
}
final json = jsonDecode(raw);
if (json is! Map) {
return null;
}
final token = (json['token'] as String? ?? '').trim();
final currentUserJson = json['currentUser'] as String? ?? '';
if (token.isEmpty || currentUserJson.trim().isEmpty) {
return null;
}
return DurableAuthSession(token: token, currentUserJson: currentUserJson);
} catch (_) {
return null;
}
}
static Future<void> clearSession() async {
if (!Platform.isIOS) {
return;
}
await _invokeSafely('delete', <String, Object>{'key': _sessionKey});
}
static Future<void> _invokeSafely(
String method,
Map<String, Object> arguments,
) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} catch (_) {}
}
}
class DurableAuthSession {
const DurableAuthSession({
required this.token,
required this.currentUserJson,
});
final String token;
final String currentUserJson;
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
@ -5,6 +6,7 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_message_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/durable_auth_storage.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
@ -48,6 +50,12 @@ class AccountStorage {
String userJson = jsonEncode(_currentUser?.toJson());
if (userJson.isNotEmpty) {
DataPersistence.setCurrentUser(userJson);
unawaited(
DurableAuthStorage.saveSession(
token: _currentUser!.token ?? "",
currentUserJson: userJson,
),
);
}
}
@ -119,6 +127,30 @@ class AccountStorage {
return token;
}
Future<void> restoreDurableSessionIfNeeded() async {
final localToken = DataPersistence.getToken().trim();
final localUserJson = DataPersistence.getCurrentUser().trim();
if (localToken.isNotEmpty && localUserJson.isNotEmpty) {
return;
}
final session = await DurableAuthStorage.readSession();
if (session == null) {
return;
}
token = session.token;
await DataPersistence.setToken(session.token);
await DataPersistence.setCurrentUser(session.currentUserJson);
try {
_currentUser = SocialChatLoginRes.fromJson(
jsonDecode(session.currentUserJson),
);
} catch (_) {
_currentUser = null;
}
}
void setToken(String tk) {
token = tk;
DataPersistence.setToken(token);
@ -129,6 +161,7 @@ class AccountStorage {
_currentUser = null;
DataPersistence.setToken("");
DataPersistence.setCurrentUser("");
unawaited(DurableAuthStorage.clearSession());
}
///退

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
@ -11,7 +11,7 @@ import 'package:yumi/shared/data_sources/sources/remote/net/sc_logger.dart';
NetworkClient get http => _httpInstance;
late final NetworkClient _httpInstance = NetworkClient();
final NetworkClient _httpInstance = NetworkClient();
final RegExp _encryptedRoutePattern = RegExp(r'^[0-9a-f]{32,}$');
@ -439,13 +439,15 @@ class ResponseData<T> {
Map<String, dynamic> json, {
T Function(dynamic)? fromJsonT,
}) {
final dynamic responseBody =
json.containsKey('body') ? json['body'] : json['data'];
if (fromJsonT == null) {
return ResponseData(
status: json['status'],
errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'],
body: json['body'],
body: responseBody,
time: json['time'],
);
} else {
@ -454,7 +456,7 @@ class ResponseData<T> {
errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'],
body: json['body'] != null ? _parseData(json['body'], fromJsonT) : null,
body: responseBody != null ? _parseData(responseBody, fromJsonT) : null,
time: json['time'],
);
}
@ -486,8 +488,8 @@ class TimeOutInterceptor extends Interceptor {
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout) {
print('超时错误: URL => ${err.requestOptions.baseUrl}');
print('当前使用的Host: ${SCGlobalConfig.apiHost}');
debugPrint('超时错误: URL => ${err.requestOptions.baseUrl}');
debugPrint('当前使用的Host: ${SCGlobalConfig.apiHost}');
}
//
@ -510,7 +512,7 @@ class TimeOutInterceptor extends Interceptor {
_timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () {
SCLoadingManager.hide();
if (!cancelToken.isCancelled) {
cancelToken.cancel('请求超时(${timeoutSeconds}');
cancelToken.cancel('请求超时($timeoutSeconds秒');
//
_timers.remove(requestKey);
}

View File

@ -0,0 +1,308 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/business_logic/repositories/vip_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
const String _vipApiHost = String.fromEnvironment(
'VIP_API_HOST',
defaultValue: '',
);
class SCVipRepositoryImp implements SocialChatVipRepository {
static SCVipRepositoryImp? _instance;
SCVipRepositoryImp._internal();
factory SCVipRepositoryImp() {
return _instance ??= SCVipRepositoryImp._internal();
}
@override
Future<SCVipStatusRes> vipStatus() async {
final path = _vipPath('/status');
_logRequest('GET', path);
try {
final result = await http.get<SCVipStatusRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipStatusRes.fromJson(json),
);
_logResponse('GET', path, _statusSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
Future<SCVipHomeRes> vipHome() async {
final path = _vipPath('/home');
_logRequest('GET', path);
try {
final result = await http.get<SCVipHomeRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipHomeRes.fromJson(json),
);
_logResponse('GET', path, _homeSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
}) async {
if (targetLevel == null && (targetLevelCode?.isEmpty ?? true)) {
throw ArgumentError('targetLevel or targetLevelCode is required');
}
final params = <String, dynamic>{};
if (targetLevel != null) {
params['targetLevel'] = targetLevel;
}
if (targetLevelCode != null && targetLevelCode.isNotEmpty) {
params['targetLevelCode'] = targetLevelCode;
}
final path = _vipPath('/preview');
_logRequest('POST', path, body: params);
try {
final result = await http.post<SCVipPreviewRes>(
path,
data: params,
extra: _vipExtra(),
fromJson: (json) => SCVipPreviewRes.fromJson(json),
);
_logResponse('POST', path, _previewSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: params);
rethrow;
}
}
@override
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
}) async {
final path = _vipPath('/purchase');
final body = {
'targetLevel': targetLevel,
'bizIdempotentKey': bizIdempotentKey,
};
_logRequest('POST', path, body: body);
try {
final result = await http.post<SCVipPurchaseRes>(
path,
data: body,
extra: _vipExtra(),
fromJson: (json) => SCVipPurchaseRes.fromJson(json),
);
_logResponse('POST', path, _purchaseSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: body);
rethrow;
}
}
@override
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20}) async {
final path = _vipPath('/orders');
final query = {'cursor': cursor, 'limit': limit};
_logRequest('GET', path, query: query);
try {
final result = await http.get<SCVipOrdersRes>(
path,
queryParams: query,
extra: _vipExtra(),
fromJson: (json) => SCVipOrdersRes.fromJson(json),
);
_logResponse('GET', path, _ordersSummary(result));
return result;
} catch (error) {
_logError('GET', path, error, query: query);
rethrow;
}
}
static bool get _hasExplicitVipApiHost => _vipApiHost.trim().isNotEmpty;
static String get _activeBaseUrl =>
_hasExplicitVipApiHost ? _vipApiHost.trim() : SCGlobalConfig.apiHost;
static String _vipPath(String suffix) {
final baseUrl = _activeBaseUrl.trim();
final parsed = Uri.tryParse(baseUrl);
final pathSegments =
parsed?.pathSegments.where((segment) => segment.isNotEmpty).toSet() ??
<String>{};
final baseAlreadyContainsGo = pathSegments.contains('go');
if (_hasExplicitVipApiHost || baseAlreadyContainsGo) {
return '/app/vip$suffix';
}
return '/go/app/vip$suffix';
}
static Map<String, dynamic>? _vipExtra() {
if (!_hasExplicitVipApiHost) {
return null;
}
return {BaseNetworkClient.baseUrlOverrideKey: _vipApiHost.trim()};
}
static void _logRequest(
String method,
String path, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Request] $method $path '
'baseUrl=$_activeBaseUrl query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})}',
);
}
static void _logResponse(
String method,
String path,
Map<String, dynamic> summary,
) {
debugPrint('[VIP][Response] $method $path summary=${_json(summary)}');
}
static void _logError(
String method,
String path,
Object error, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Error] $method $path query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})} error=$error',
);
if (error is DioException) {
debugPrint(
'[VIP][DioError] $method $path '
'uri=${error.requestOptions.uri} '
'statusCode=${error.response?.statusCode} '
'message=${error.message} '
'response=${_json(error.response?.data)}',
);
}
}
static Map<String, dynamic> _statusSummary(SCVipStatusRes result) {
return {
'active': result.active,
'status': result.status,
'level': result.level,
'levelCode': result.levelCode,
'expireAt': result.expireAt,
'badge': _resourceSummary(result.badge),
'avatarFrame': _resourceSummary(result.avatarFrame),
'entryEffect': _resourceSummary(result.entryEffect),
'chatBubble': _resourceSummary(result.chatBubble),
'floatPicture': _resourceSummary(result.floatPicture),
};
}
static Map<String, dynamic> _homeSummary(SCVipHomeRes result) {
return {
'configured': result.configured,
'state': result.state == null ? null : _statusSummary(result.state!),
'levelsCount': result.levels.length,
'levels':
result.levels
.map(
(level) => {
'level': level.level,
'levelCode': level.levelCode,
'enabled': level.enabled,
'durationDays': level.durationDays,
'priceGold': level.priceGold,
'canPurchase': level.canPurchase,
'payableGold': level.payableGold,
'badge': _resourceSummary(level.badge),
'avatarFrame': _resourceSummary(level.avatarFrame),
'entryEffect': _resourceSummary(level.entryEffect),
'chatBubble': _resourceSummary(level.chatBubble),
'floatPicture': _resourceSummary(level.floatPicture),
},
)
.toList(),
};
}
static Map<String, dynamic>? _resourceSummary(SCVipResourceRes? resource) {
if (resource?.hasContent != true) {
return null;
}
return {
'id': resource?.resourceId,
'name': resource?.name,
'hasPreviewUrl': resource?.hasPreviewUrl == true,
'hasSourceUrl': resource?.hasSourceUrl == true,
'previewUrl': resource?.previewUrl,
'sourceUrl': resource?.sourceResourceUrl,
};
}
static Map<String, dynamic> _previewSummary(SCVipPreviewRes result) {
return {
'orderType': result.orderType,
'currentLevel': result.currentLevel,
'targetLevel': result.targetLevel,
'durationDays': result.durationDays,
'originalPriceGold': result.originalPriceGold,
'payableGold': result.payableGold,
'expireAt': result.expireAt,
};
}
static Map<String, dynamic> _purchaseSummary(SCVipPurchaseRes result) {
final order = result.order;
return {
'success': result.success,
'order':
order == null
? null
: {
'id': order.id,
'orderType': order.orderType,
'fromLevel': order.fromLevel,
'toLevel': order.toLevel,
'paidGold': order.paidGold,
'status': order.status,
'expireAt': order.expireAt,
},
'state': result.state == null ? null : _statusSummary(result.state!),
};
}
static Map<String, dynamic> _ordersSummary(SCVipOrdersRes result) {
return {'recordsCount': result.records.length, 'total': result.total};
}
static String _json(dynamic value) {
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}
}

View File

@ -17,6 +17,7 @@ class SCHeartbeatUtils {
String status,
bool upMick, {
String? roomId,
bool rethrowOnError = false,
}) async {
cancelTimer();
_c = status;
@ -40,6 +41,9 @@ class SCHeartbeatUtils {
try {
cancelTimer();
} catch (_) {}
if (rethrowOnError) {
rethrow;
}
}
}

View File

@ -1,23 +1,42 @@
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils{
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
}
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils {
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
static Future<bool> checkAudioPermission() async {
if (!Platform.isAndroid) {
return true;
}
final androidInfo = await DeviceInfoPlugin().androidInfo;
final permission =
androidInfo.version.sdkInt >= 33
? Permission.audio
: Permission.storage;
var status = await permission.status;
if (status.isDenied || status.isRestricted || status.isLimited) {
status = await permission.request();
}
return status.isGranted;
}
}

View File

@ -109,10 +109,7 @@ class SCChatRoomHelper {
).retrieveMicrophoneList();
Provider.of<RealTimeCommunicationManager>(context, listen: false)
.closeFullGame = true;
SCNavigatorUtils.push(
context,
'${VoiceRoomRoute.voiceRoom}?id=${Provider.of<RealTimeCommunicationManager>(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}',
);
VoiceRoomRoute.openVoiceRoom(context);
}
static double getCurrenProgress(

View File

@ -1,133 +1,123 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/index/main_route.dart';
import '../../components/sc_float_ichart.dart';
class ExitMinRoomPage extends StatefulWidget {
bool isFz;
String roomId;
ExitMinRoomPage(this.isFz, this.roomId);
@override
_ExitMinRoomPageState createState() => _ExitMinRoomPageState();
}
class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
SCUserIdentityRes? userIdentity;
bool isLoading = true;
@override
void initState() {
super.initState();
SCAccountRepository()
.userIdentity(
userId:
Provider.of<RtcProvider>(
context,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.userId,
)
.then((v) {
userIdentity = v;
isLoading = false;
setState(() {});
})
.catchError((e) {
isLoading = false;
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
SCDebounceWidget(
child: Container(color: Colors.transparent),
onTap: () {
Navigator.of(context).pop();
},
),
Column(
spacing: 55.w,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_min_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.mInimize,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
SCFloatIchart().show();
SCNavigatorUtils.popUntil(
context,
ModalRoute.withName(SCRoutes.home),
);
Provider.of<GiftAnimationManager>(
context,
listen: false,
).cleanupAnimationResources();
},
),
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_exit_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.exit,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
SCNavigatorUtils.goBack(context);
Provider.of<RtcProvider>(
context,
listen: false,
).exitCurrentVoiceRoomSession(false);
},
),
],
),
],
);
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart';
import '../../components/sc_float_ichart.dart';
class ExitMinRoomPage extends StatefulWidget {
final bool isFz;
final String roomId;
const ExitMinRoomPage(this.isFz, this.roomId, {super.key});
@override
State<ExitMinRoomPage> createState() => _ExitMinRoomPageState();
}
class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
SCDebounceWidget(
child: Container(color: Colors.transparent),
onTap: () {
Navigator.of(context).pop();
},
),
Column(
spacing: 55.w,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_min_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.mInimize,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () async {
final rtcProvider = context.read<RtcProvider>();
if (rtcProvider.roomStartupStatus ==
RoomStartupStatus.loading ||
rtcProvider.roomStartupStatus == RoomStartupStatus.failed) {
SCNavigatorUtils.goBack(context);
await rtcProvider.exitCurrentVoiceRoomSession(false);
return;
}
final giftAnimationManager =
context.read<GiftAnimationManager>();
final rtmProvider = context.read<RtmProvider>();
rtcProvider.setRoomVisualEffectsEnabled(false);
rtmProvider.msgFloatingGiftListener = null;
rtmProvider.msgLuckyGiftRewardTickerListener = null;
RoomEntranceHelper.clearQueue();
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(
reason: 'room_minimize',
);
SCGiftVapSvgaManager().stopPlayback();
unawaited(context.read<RoomMusicManager>().stopForRoomExit());
giftAnimationManager.cleanupAnimationResources();
SCFloatIchart().show();
SCNavigatorUtils.popUntil(
context,
ModalRoute.withName(SCRoutes.home),
);
},
),
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_exit_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.exit,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
final rtcProvider = context.read<RtcProvider>();
SCNavigatorUtils.goBack(context);
rtcProvider.exitCurrentVoiceRoomSession(false);
},
),
],
),
],
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicFloatingEntry extends StatelessWidget {
const RoomMusicFloatingEntry({super.key});
@override
Widget build(BuildContext context) {
return Selector<RoomMusicManager, bool>(
selector: (_, manager) => manager.current != null,
builder: (context, visible, child) {
if (!visible) {
return const SizedBox.shrink();
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: context.read<RoomMusicManager>().toggleRoomPlayerVisible,
child: SizedBox(
width: 44.w,
height: 44.w,
child: Image.asset(
"sc_images/room/sc_music_material_room_side.png",
fit: BoxFit.contain,
),
),
);
},
);
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicPlayerBar extends StatelessWidget {
const RoomMusicPlayerBar({super.key});
static const String _playAsset = "sc_images/room/sc_music_material_play.png";
static const String _pauseAsset =
"sc_images/room/sc_music_material_pause.png";
static const String _previousAsset =
"sc_images/room/sc_icon_room_music_previous.png";
static const String _nextAsset = "sc_images/room/sc_icon_room_music_next.png";
static const String _volumeAsset =
"sc_images/room/sc_music_material_volume.png";
@override
Widget build(BuildContext context) {
return Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final current = manager.current;
if (current == null) {
return const SizedBox.shrink();
}
final duration = manager.durationMs <= 0 ? 1 : manager.durationMs;
final position = manager.positionMs.clamp(0, duration).toInt();
return Container(
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xF20B3029),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 18.w,
offset: Offset(0, -6.w),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
current.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
],
),
SizedBox(height: 6.w),
Row(
children: [
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 6.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: duration.toDouble(),
value: position.toDouble(),
onChanged:
(value) => manager.seek(value.round().toInt()),
),
),
),
Text(
_formatDuration(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
],
),
SizedBox(height: 4.w),
Row(
children: [
_IconButton(
asset: _modeAsset(manager.playMode),
size: 34.w,
onTap: () {
final message = manager.switchPlayMode();
ScaffoldMessenger.maybeOf(
context,
)?.hideCurrentSnackBar();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(_localizedModeText(context, message)),
duration: const Duration(milliseconds: 900),
),
);
},
),
const Spacer(),
_IconButton(
asset: _previousAsset,
size: 34.w,
onTap: () {
manager.previous();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: manager.isPlaying ? _pauseAsset : _playAsset,
size: 48.w,
onTap: () {
manager.isPlaying ? manager.pause() : manager.resume();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: _nextAsset,
size: 34.w,
onTap: () {
manager.next();
},
),
const Spacer(),
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
SizedBox(
width: 82.w,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 5.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: 100,
value: manager.volume.toDouble(),
onChanged: (value) {
manager.setVolume(value.round());
},
),
),
),
],
),
],
),
),
);
},
);
}
static String _formatDuration(int milliseconds) {
final totalSeconds = (milliseconds / 1000).floor();
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return "$minutes:${seconds.toString().padLeft(2, '0')}";
}
static String _modeAsset(RoomMusicPlayMode mode) {
switch (mode) {
case RoomMusicPlayMode.list:
return "sc_images/room/sc_music_material_sequence.png";
case RoomMusicPlayMode.shuffle:
return "sc_images/room/sc_music_material_shuffle.png";
case RoomMusicPlayMode.single:
return "sc_images/room/sc_music_material_single_loop.png";
}
}
static String _localizedModeText(BuildContext context, String message) {
switch (message) {
case "随机播放":
return RoomMusicTexts.t(context, "roomMusicShuffleMode", message);
case "单曲循环播放":
return RoomMusicTexts.t(context, "roomMusicSingleMode", message);
case "列表顺序播放":
default:
return RoomMusicTexts.t(context, "roomMusicListMode", message);
}
}
}
class _IconButton extends StatelessWidget {
const _IconButton({
required this.asset,
required this.size,
required this.onTap,
});
final String asset;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: size,
height: size,
child: Center(child: Image.asset(asset, width: size, height: size)),
),
);
}
}

View File

@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicRoomPlayer extends StatelessWidget {
const RoomMusicRoomPlayer({super.key});
static const String _closeAsset =
"sc_images/room/sc_music_room_player_close.png";
static const String _minimizeAsset =
"sc_images/room/sc_music_room_player_minimize.png";
static const String _manageAsset =
"sc_images/room/sc_music_room_player_manage.png";
static const String _previousAsset =
"sc_images/room/sc_music_room_player_previous.png";
static const String _nextAsset =
"sc_images/room/sc_music_room_player_next.png";
static const String _playAsset = "sc_images/room/sc_music_material_play.png";
static const String _pauseAsset =
"sc_images/room/sc_music_material_pause.png";
static const String _volumeAsset =
"sc_images/room/sc_music_material_volume.png";
@override
Widget build(BuildContext context) {
return Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final current = manager.current;
if (current == null || !manager.roomPlayerVisible) {
return const SizedBox.shrink();
}
final duration = manager.durationMs <= 0 ? 1 : manager.durationMs;
final position = manager.positionMs.clamp(0, duration).toInt();
return Container(
padding: EdgeInsets.fromLTRB(16.w, 10.w, 16.w, 10.w),
decoration: BoxDecoration(
color: const Color(0xE6072A24),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.10)),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_PlainIconButton(
asset: _closeAsset,
size: 24.w,
onTap: () => manager.stop(clearCurrent: true),
),
Expanded(
child: Text(
current.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
_PlainIconButton(
asset: _minimizeAsset,
size: 24.w,
onTap: manager.hideRoomPlayer,
),
],
),
SizedBox(height: 6.w),
Row(
children: [
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
SizedBox(width: 8.w),
Expanded(
child: _MusicSlider(
value: position.toDouble(),
max: duration.toDouble(),
onChanged: (value) => manager.seek(value.round()),
),
),
SizedBox(width: 8.w),
Text(
_formatDuration(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
],
),
SizedBox(height: 12.w),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_PlainIconButton(
asset: _modeAsset(manager.playMode),
size: 28.w,
onTap: manager.switchPlayMode,
),
_PlainIconButton(
asset: _previousAsset,
size: 42.w,
onTap: manager.previous,
),
_PlainIconButton(
asset: manager.isPlaying ? _pauseAsset : _playAsset,
size: 42.w,
onTap: () {
manager.isPlaying ? manager.pause() : manager.resume();
},
),
_PlainIconButton(
asset: _nextAsset,
size: 42.w,
onTap: manager.next,
),
_PlainIconButton(
asset: _manageAsset,
size: 28.w,
onTap:
() => VoiceRoomRoute.openRoomMusic(
context,
rootNavigator: true,
),
),
],
),
SizedBox(height: 12.w),
Row(
children: [
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
SizedBox(width: 8.w),
Expanded(
child: _MusicSlider(
value: manager.volume.toDouble(),
max: 100,
onChanged: (value) => manager.setVolume(value.round()),
),
),
SizedBox(width: 8.w),
SizedBox(
width: 38.w,
child: Text(
"${manager.volume}%",
textAlign: TextAlign.end,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
),
],
),
],
),
),
);
},
);
}
static String _formatDuration(int milliseconds) {
final totalSeconds = (milliseconds / 1000).floor();
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return "$minutes:${seconds.toString().padLeft(2, '0')}";
}
static String _modeAsset(RoomMusicPlayMode mode) {
switch (mode) {
case RoomMusicPlayMode.list:
return "sc_images/room/sc_music_material_sequence.png";
case RoomMusicPlayMode.shuffle:
return "sc_images/room/sc_music_material_shuffle.png";
case RoomMusicPlayMode.single:
return "sc_images/room/sc_music_material_single_loop.png";
}
}
}
class _MusicSlider extends StatelessWidget {
const _MusicSlider({
required this.value,
required this.max,
required this.onChanged,
});
final double value;
final double max;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final safeMax = max <= 0 ? 1.0 : max;
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3.w,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 5.w),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(alpha: 0.18),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: safeMax,
value: value.clamp(0, safeMax).toDouble(),
onChanged: onChanged,
),
);
}
}
class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({
required this.asset,
required this.size,
required this.onTap,
});
final String asset;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: size + 12.w,
height: size + 12.w,
child: Center(child: Image.asset(asset, width: size, height: size)),
),
);
}
}

View File

@ -37,9 +37,9 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuSound = 4;
static const int _menuReport = 5;
static const int _menuBackground = 6;
static const int _menuMusic = 7;
static bool get _showBackgroundFeature => false;
List<RoomMenu> items1 = [];
List<RoomMenu> items2 = [];
@override
@ -49,14 +49,6 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
@override
Widget build(BuildContext context) {
// items1.clear();
// items1.add(
// RoomMenu(
// 0,
// SCAppLocalizations.of(context)!.music,
// "sc_icon_room_music_tag.png",
// ),
// );
// items1.add(
// RoomMenu(
// 1,
@ -129,6 +121,21 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
"sc_icon_room_background.png",
),
);
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
} else {
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
}
if (!Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
@ -191,7 +198,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("sc_images/room/${item.icon}", width: 45.w, height: 45.w),
_buildMenuIcon(item),
SizedBox(height: 10.w),
text(
item.title,
@ -206,7 +213,13 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
],
),
onTap: () async {
if (item.id == _menuMicManagement) {
if (item.id == _menuMusic) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
VoiceRoomRoute.openRoomMusic(
navigatorKey.currentState!.context,
rootNavigator: true,
);
} else if (item.id == _menuMicManagement) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SmartDialog.show(
tag: "showRoomMicSwitch",
@ -279,6 +292,30 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
},
);
}
Widget _buildMenuIcon(RoomMenu item) {
if (item.id != _menuMusic) {
return Image.asset(
"sc_images/room/${item.icon}",
width: 45.w,
height: 45.w,
);
}
return Container(
width: 45.w,
height: 45.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.16),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/${item.icon}",
width: 26.w,
height: 26.w,
),
);
}
}
class RoomMenu {

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart';
import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart';
class RoomPlayWidget extends StatefulWidget {
const RoomPlayWidget({super.key});
@ -16,6 +17,11 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
return SizedBox.expand(
child: Stack(
children: [
PositionedDirectional(
end: 15.w,
bottom: 150.w,
child: const RoomMusicFloatingEntry(),
),
PositionedDirectional(
end: 15.w,
bottom: 100.w,

View File

@ -98,6 +98,7 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
configuredSeatCount:
provider.currenRoom?.roomProfile?.roomSetting?.mikeSize
?.toInt() ??
provider.previewRoomSeatCount ??
0,
hasCurrentRoom: provider.currenRoom != null,
isExitingCurrentVoiceRoomSession:

View File

@ -765,6 +765,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
id3:
dependency: transitive
description:
name: id3
sha256: "24176a6e08db6297c8450079e94569cd8387f913c817e5e3d862be7dc191e0b8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
image:
dependency: transitive
description:
@ -1068,6 +1076,44 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
on_audio_query:
dependency: "direct main"
description:
path: "local_packages/on_audio_query-2.9.0"
relative: true
source: path
version: "2.9.0"
on_audio_query_android:
dependency: transitive
description:
path: "local_packages/on_audio_query_android-1.1.0"
relative: true
source: path
version: "1.1.0"
on_audio_query_ios:
dependency: transitive
description:
name: on_audio_query_ios
sha256: "9b3efa39a656fa3720980e3c6a1f55b7257d0032a45ffeb3f70eaa2c7f10f929"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
on_audio_query_platform_interface:
dependency: transitive
description:
name: on_audio_query_platform_interface
sha256: c23e019a31bd0774828476e428fd33b0dd1d82c9d4791dba80429358fc65dcd3
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.7.0"
on_audio_query_web:
dependency: transitive
description:
name: on_audio_query_web
sha256: "990efa52d879e6caffa97f24b34acd9caa1ce2c4c4cb873fe5a899a9b1af02c7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
package_config:
dependency: transitive
description:
@ -1093,7 +1139,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.2.5+7
version: 1.2.6+8
environment:
@ -118,9 +118,12 @@ dependencies:
#缩略图
video_thumbnail: ^0.5.6
badges: ^3.1.2
#本地路径
path_provider: ^2.1.4
photo_view: ^0.15.0
#本地路径
path_provider: ^2.1.4
path: ^1.9.0
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
photo_view: ^0.15.0
flutter_staggered_grid_view: ^0.7.0
marquee: ^2.3.0
extended_nested_scroll_view: ^6.2.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

View File

@ -128,4 +128,54 @@ void main() {
expect(find.byType(SCSeatItem), findsNWidgets(10));
},
);
testWidgets('SCSeatItem keeps height stable when an empty seat gets a user', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 1200));
addTearDown(() async {
await tester.binding.setSurfaceSize(null);
});
final rtcProvider =
RealTimeCommunicationManager()
..currenRoom = JoinRoomRes(
roomProfile: RoomProfile(roomSetting: RoomSetting(mikeSize: 10)),
)
..roomWheatMap = {0: MicRes(micIndex: 0)};
Future<void> pumpSeat() {
return tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder:
(_, __) =>
ChangeNotifierProvider<RealTimeCommunicationManager>.value(
value: rtcProvider,
child: const MaterialApp(
home: Scaffold(body: Center(child: SCSeatItem(index: 0))),
),
),
),
);
}
await pumpSeat();
final emptySeatSize = tester.getSize(find.byType(SCSeatItem));
rtcProvider.roomWheatMap = {
0: MicRes(
micIndex: 0,
user: SocialChatUserProfile(
id: 'user-1',
userAvatar: '',
userNickname: 'Alice',
heartbeatVal: 1234,
),
),
};
await pumpSeat();
expect(tester.getSize(find.byType(SCSeatItem)), emptySeatSize);
});
}

View File

@ -2,6 +2,25 @@
## 当前总目标
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
- 按桌面《Android音乐添加流程.md》在 Android 语言房内补齐本地 MP3 音乐功能,并持续记录素材接入、扫描添加、列表管理、播放器和房间播放链路进度。
## 本轮 Android 语言房音乐功能(进行中)
- 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。
- 已导入桌面 `音乐素材` 文件夹内的 12 张音乐相关 PNG并统一落到 `sc_images/room/sc_music_material_*`,当前用于房间菜单入口、播放器按钮、模式切换、音量、文件夹、更多、删除和编辑等 UI 位。
- 已恢复并接入 Android 本地音频扫描能力:`pubspec.yaml` 新增本地 `on_audio_query` 依赖Android 清单补齐 `READ_MEDIA_AUDIO`,旧版 Android 继续兼容 `READ_EXTERNAL_STORAGE`;权限申请已统一收口到 `SCUtils.checkAudioPermission()`
- 已扩展音乐数据结构:`SCMusicMode` 已从旧的简单歌曲字段扩展为可持久化的本地歌曲模型,新增 `SCMusicFolderMode`;同时新增本地扫描器与仓库层,支持扫描 MP3、按文件夹分组、去重保存、排序归一、删除和本地 JSON 持久化。
- 已新增语言房音乐全局管理器 `RoomMusicManager`,统一管理播放列表、当前歌曲、播放/暂停/停止、上一首/下一首、播放进度、音量、顺序播放/随机播放/单曲循环,并根据麦上/麦下状态切换声网混音的 `loopback` 行为:麦下仅本地播放,麦上可推送到房间。
- 已在声网管理层补齐房间音乐专用封装:新增音乐混音状态回调、麦上状态变化回调,以及开始播放、暂停、恢复、停止、设置进度、读取进度、设置本地和远端混音音量等方法,供房间音乐管理器调用。
- 已完成首版页面链路:新增音乐主页、文件夹列表页、文件夹歌曲选择页、底部播放器和房间内悬浮音乐入口;当前支持空态、扫描添加、批量选择、已添加状态识别、搜索、左滑删除、编辑排序、播放器进度条、音量条、模式切换、暂停/播放和上下首切换。
- 已接入语言房入口:房间底部菜单已恢复 `Music` 入口并跳转音乐页;语音房页面已挂载音乐悬浮入口,离开房间时会主动停止当前房间音乐,避免混音残留到房外页面。
- 已按最新反馈调整房间菜单位置:`Music` 不再单独占用菜单第一行,而是并入常规菜单网格,紧跟在 `Background` 图标之后;背景入口功能开关保持打开状态。
- 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。
- 已按最新音乐页与房间播放器设计继续对稿:音乐管理页右上角只保留添加按钮,排序/编辑按钮移到 `Total songs` 行末;空态不再展示搜索框,搜索框圆角收到 `17`;歌曲 item 改为 `#08251E` 背景和纵向渐隐描边,播放/暂停按钮移到右侧;左滑删除改为固定 `60` 宽删除按钮,需要点击删除按钮后才删除;音乐相关页面点击波浪反馈已收口移除。
- 已重做房间内音乐入口与播放器:右侧音乐浮标不再旋转,并移动到游戏图标上方;点击浮标改为在房间内打开/隐藏播放器,不再直接进入歌曲管理页;房间播放器使用新增的关闭、最小化、上一首、下一首、进入管理页等素材;当前用户在麦上推送音乐时,自己麦位头像左下角会显示并旋转 `用户正在播放bgm` 标识。
- 已继续修正音乐管理页交互细节:歌曲删除按钮现改为与 item 同一行滑出,不再作为底层背景露出;排序拖拽时使用透明 proxy去掉默认白边音乐管理页底部播放器去掉进度条上方多余当前时间和右侧关闭按钮音乐管理页、文件夹页和歌曲选择页的路由切换改为深色 route减少进入/添加音乐时的白屏闪烁。
- 已顺带收口背景相关页面的路由白屏问题:背景选择页、背景上传页和背景预览页现统一走深色 route避免切换瞬间露出默认白底。
- 已补齐 `en/ar/tr/bn` 四套现有多语言文案;当前项目声明支持 `zh`,但仓库里暂无 `assets/l10n/intl_zh.json`,因此中文兜底仍由页面内 fallback 文案承担。
- 验证进度:`flutter pub get` 已通过;音乐相关目录与房间接线文件的定向 `flutter analyze` 无 error/warning当前仅 `rtc_manager.dart``sc_seat_item.dart` 保留项目原有 info 级别风格/废弃 API 提示;全量 `flutter analyze` 仍被仓库既有测试依赖缺失和测试 API 问题阻断;`flutter build apk --debug` 已通过并生成 `build/app/outputs/flutter-apk/app-debug.apk`
## 本轮语言房进房加载逻辑优化(已完成)
- 已按竞品加载思路梳理当前语言房进房链路:当前业务 `entryRoom` 成功后会先进入房间页IM 加群、声网入会、麦位与在线用户刷新在房间页展示后继续进行这与“IM 和声网都完成后才进入完整房间”的体验存在差异。