diff --git a/KTVAPI/Android/README.md b/KTVAPI/Android/README.md
index bb25d88..240eed6 100644
--- a/KTVAPI/Android/README.md
+++ b/KTVAPI/Android/README.md
@@ -1,10 +1,10 @@
# K 歌场景化 API 示例 demo
-> 本文档主要介绍如何快速跑通 K 歌场景化 API 示例工程,本 demo 支持普通合唱、大合唱两种模式, 包含加载、播放声网内容中心版权音乐和本地音乐文件等功能
+> 本文档主要介绍如何快速跑通 K 歌场景化 API 示例工程,本 demo 支持普通合唱、大合唱两种模式, 包含加载、播放声网内容中心版权音乐、本地音乐文件、声网 Ex 歌曲等功能
>
> **Demo 效果:**
>
-> 
+> 
---
## 1. 环境准备
@@ -52,6 +52,7 @@
# RTM RTC SDK key Config
AGORA_APP_ID:声网 APP ID
AGORA_APP_CERTIFICATE:声网 APP 证书
+ # Cloud Player Config
RESTFUL_API_KEY:声网RESTful API key
RESTFUL_API_SECRET:声网RESTful API secret
```
diff --git a/KTVAPI/Android/app/build.gradle b/KTVAPI/Android/app/build.gradle
index 241ca82..213547c 100644
--- a/KTVAPI/Android/app/build.gradle
+++ b/KTVAPI/Android/app/build.gradle
@@ -9,12 +9,12 @@ properties.load(inputStream)
android {
namespace 'io.agora.ktvdemo'
- compileSdk 31
+ compileSdk 33
defaultConfig {
applicationId "io.agora.ktvdemo"
minSdk 21
- targetSdk 31
+ targetSdk 33
versionCode 1
versionName "1.0"
@@ -25,6 +25,11 @@ android {
buildConfigField "String", "AGORA_APP_CERTIFICATE", "\"${properties.getProperty("AGORA_APP_CERTIFICATE", "")}\""
buildConfigField "String", "RESTFUL_API_KEY", "\"${RESTFUL_API_KEY}\""
buildConfigField "String", "RESTFUL_API_SECRET", "\"${RESTFUL_API_SECRET}\""
+
+ buildConfigField "String", "EX_APP_ID", "\"${properties.getProperty("EX_APP_ID", "")}\""
+ buildConfigField "String", "EX_APP_Key", "\"${properties.getProperty("EX_APP_Key", "")}\""
+ buildConfigField "String", "EX_APP_TOKEN", "\"${properties.getProperty("EX_APP_TOKEN", "")}\""
+ buildConfigField "String", "EX_USERID", "\"${properties.getProperty("EX_USERID", "")}\""
}
buildTypes {
@@ -80,11 +85,18 @@ dependencies {
implementation 'com.github.mrmike:ok2curl:0.8.0'
// 歌词组件
- implementation 'com.github.AgoraIO-Community:LyricsView:1.1.1-beta.8'
+ implementation 'com.github.AgoraIO-Community:LyricsView:1.1.4'
+
+ // 歌词组件 Ex
+ implementation 'io.github.winskyan:Agora-LyricsViewEx:2.0.0.130'
//
implementation 'io.agora:authentication:1.6.1'
// ktvapi
api project(":lib_ktvapi")
+ api project(":lib_ktvapi_ex")
+
+// runtimeOnly project(':lib_ktvapi')
+// runtimeOnly project(':lib_ktvapi_ex')
}
static def releaseTime() {
diff --git a/KTVAPI/Android/app/src/main/AndroidManifest.xml b/KTVAPI/Android/app/src/main/AndroidManifest.xml
index a054703..7b8040a 100644
--- a/KTVAPI/Android/app/src/main/AndroidManifest.xml
+++ b/KTVAPI/Android/app/src/main/AndroidManifest.xml
@@ -16,9 +16,9 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
- android:icon="@mipmap/app_ic_launcher"
+ android:icon="@mipmap/app_ktv_ic_launcher"
android:label="@string/app_name"
- android:roundIcon="@mipmap/app_ic_launcher_round"
+ android:roundIcon="@mipmap/app_ktv_ic_launcher"
android:supportsRtl="true"
android:theme="@style/app_Theme.AgoraKTV"
android:networkSecurityConfig="@xml/network_security_config"
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt
index 2d7b2a0..faa07bb 100644
--- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt
@@ -77,7 +77,7 @@ class CloudApiManager private constructor() {
return token
}
- fun fetchStartCloud(mainChannel: String, inputToken: String, outputToken: String) {
+ fun fetchStartCloud(mainChannel: String, inputRtcUid: Int, inputToken: String, outputToken: String) {
val token = fetchCloudToken()
tokenName = token.ifEmpty {
Log.e(TAG, "云端合流uid 请求报错 token is null")
@@ -87,7 +87,7 @@ class CloudApiManager private constructor() {
try {
val transcoderObj = JSONObject()
val inputRetObj = JSONObject()
- .put("rtcUid", 0)
+ .put("rtcUid", inputRtcUid)
.put("rtcToken", inputToken)
.put("rtcChannel", mainChannel)
val intObj = JSONObject()
@@ -195,7 +195,7 @@ class CloudApiManager private constructor() {
return "Basic $base64Credentials"
}
- private fun getString(resId:Int):String{
+ private fun getString(resId: Int): String {
return MyApplication().getString(resId)
}
}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt
index 70c7608..1b03a5b 100644
--- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt
@@ -1,23 +1,29 @@
package io.agora.ktvdemo.ui
+import android.content.Context
import android.os.Bundle
-import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
import androidx.navigation.fragment.findNavController
import io.agora.karaoke_view.v11.KaraokeView
+import io.agora.karaoke_view_ex.constants.DownloadError
+import io.agora.karaoke_view_ex.downloader.LyricsFileDownloader
+import io.agora.karaoke_view_ex.downloader.LyricsFileDownloaderCallback
import io.agora.ktvapi.*
import io.agora.ktvdemo.BuildConfig
+import io.agora.ktvdemo.MyApplication
import io.agora.ktvdemo.R
import io.agora.ktvdemo.api.CloudApiManager
import io.agora.ktvdemo.databinding.FragmentLivingBinding
import io.agora.ktvdemo.rtc.RtcEngineController
-import io.agora.ktvdemo.utils.DownloadUtils
import io.agora.ktvdemo.utils.KeyCenter
+import io.agora.ktvdemo.utils.SongSourceType
import io.agora.ktvdemo.utils.TokenGenerator
-import io.agora.ktvdemo.utils.ZipUtils
import io.agora.rtc2.ChannelMediaOptions
import io.agora.rtc2.IRtcEngineEventHandler
import io.agora.rtc2.RtcConnection
@@ -52,19 +58,31 @@ class LivingFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ ViewCompat.setOnApplyWindowInsetsListener(view) { v: View?, insets: WindowInsetsCompat ->
+ val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPaddingRelative(inset.left, 0, inset.right, 0)
+ WindowInsetsCompat.CONSUMED
+ }
- // 大合唱模式下主唱需要启动云端合流
- if (KeyCenter.role == KTVSingRole.LeadSinger && !KeyCenter.isNormalChorus) {
+ val sceneName =
+ if (KeyCenter.isNormalChorus) getString(R.string.app_normal_ktvapi_tag) else getString(R.string.app_giant_ktvapi_tag)
+ val suffix = if (KeyCenter.songSourceType==SongSourceType.Mcc) "Mcc" else "Local"
+ binding?.tvChorusScene?.text = "$sceneName Channel:${KeyCenter.channelId} $suffix"
+ // 大合唱模式下主唱需要启动云端合流
+ if (KeyCenter.isBroadcaster && !KeyCenter.isNormalChorus) {
TokenGenerator.generateToken("${KeyCenter.channelId}_ad", CloudApiManager.outputUid.toString(),
TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc,
success = { outputToken ->
- TokenGenerator.generateToken(KeyCenter.channelId, "0",
+ // uid
+ val inputRtcUid = 0
+ TokenGenerator.generateToken(KeyCenter.channelId, inputRtcUid.toString(),
TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc,
success = { inputToken ->
scheduledThreadPool.execute {
CloudApiManager.getInstance().fetchStartCloud(
KeyCenter.channelId,
+ inputRtcUid,
inputToken,
outputToken
)
@@ -86,10 +104,10 @@ class LivingFragment : BaseFragment() {
joinChannel()
// 设置麦克风初始状态,主唱默认开麦
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
ktvApi.muteMic(false)
}
- loadMusic()
+// loadMusic()
}
override fun onDestroy() {
@@ -102,7 +120,7 @@ class LivingFragment : BaseFragment() {
private fun initView() {
binding?.apply {
- karaokeView = KaraokeView(lyricsView,null)
+ karaokeView = KaraokeView(lyricsView, scoringView)
// 退出场景
btnClose.setOnClickListener {
@@ -110,258 +128,193 @@ class LivingFragment : BaseFragment() {
ktvApi.removeEventHandler(ktvApiEventHandler)
ktvApi.release()
RtcEngineController.rtcEngine.leaveChannel()
- scheduledThreadPool.execute {
- CloudApiManager.getInstance().fetchStopCloud()
+ if (KeyCenter.isBroadcaster && !KeyCenter.isNormalChorus) {
+ scheduledThreadPool.execute {
+ CloudApiManager.getInstance().fetchStopCloud()
+ }
}
findNavController().popBackStack()
}
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
tvSinger.text = getString(R.string.app_lead_singer)
- } else {
+ } else {
tvSinger.text = getString(R.string.app_audience)
}
// 加入合唱
btJoinChorus.setOnClickListener {
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
toast(getString(R.string.app_no_premission))
} else {
- if (KeyCenter.isMcc) {
- // 使用声网版权中心歌单
- val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
- KeyCenter.LeadSingerUid,
- KTVLoadMusicMode.LOAD_MUSIC_ONLY
- )
- ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener {
- override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
- Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
- // 切换身份为合唱者
- ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
- mainHandler.post {
- toast("加入合唱成功,自动开麦")
- ktvApi.muteMic(false)
- btMicStatus.text = "麦克风开"
- tvSinger.text = getString(R.string.app_co_singer)
- KeyCenter.role = KTVSingRole.CoSinger
- }
+ val songCode = KeyCenter.mccSongCode
+ // 使用声网版权中心歌单
+ val musicConfiguration = KTVLoadMusicConfiguration(
+ songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
+ KeyCenter.LeadSingerUid,
+ KTVLoadMusicMode.LOAD_MUSIC_ONLY,
+ false,
+ )
+ ktvApi.loadMusic(songCode, musicConfiguration, object : IMusicLoadStateListener {
+ override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
+ Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
+ // 切换身份为合唱者
+ ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
+ override fun onSwitchRoleSuccess() {
+ mainHandler.post {
+ toast("加入合唱成功,自动开麦")
+ ktvApi.muteMic(false)
+ btMicStatus.text = "麦克风开"
+ tvSinger.text = getString(R.string.app_co_singer)
+ btJoinChorus.isActivated = true
+ btMicOn.isActivated = true
+ btMicOff.isActivated = false
}
+ }
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
- mainHandler.post {
- toast("加入合唱失败")
- }
+ override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
+ mainHandler.post {
+ toast("加入合唱失败")
+ btJoinChorus.isActivated = false
}
- })
- }
+ }
+ })
- override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) {
- Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason")
- }
+ }
- override fun onMusicLoadProgress(
- songCode: Long,
- percent: Int,
- status: MusicLoadStatus,
- msg: String?,
- lyricUrl: String?
- ) {
- Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent")
- mainHandler.post {
- binding?.btLoadProgress?.text = "下载进度:$percent%"
- }
- }
- })
- } else {
- // 使用本地音乐文件
- val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
- KeyCenter.LeadSingerUid,
- KTVLoadMusicMode.LOAD_NONE
- )
- val songPath = requireActivity().filesDir.absolutePath + File.separator
- val songName = "不如跳舞"
- ktvApi.loadMusic("$songPath$songName.mp4", musicConfiguration)
- val fileLrc = File("$songPath$songName.xml")
- val lyricsModel = KaraokeView.parseLyricsData(fileLrc)
- karaokeView?.lyricsData = lyricsModel
-
- // 切换身份为合唱者
- ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
- mainHandler.post {
- toast("加入合唱成功,自动开麦")
- ktvApi.muteMic(false)
- btMicStatus.text = "麦克风开"
- tvSinger.text = getString(R.string.app_co_singer)
- KeyCenter.role = KTVSingRole.CoSinger
- }
- }
+ override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) {
+ Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason")
+ }
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
- mainHandler.post {
- toast("加入合唱失败")
- }
+ override fun onMusicLoadProgress(
+ songCode: Long,
+ percent: Int,
+ status: MusicLoadStatus,
+ msg: String?,
+ lyricUrl: String?
+ ) {
+ Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent")
+ mainHandler.post {
+ binding?.btLoadProgress?.text = "下载进度:$percent%"
}
- })
- }
+ }
+ })
}
}
// 退出合唱
btLeaveChorus.setOnClickListener {
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
toast(getString(R.string.app_no_premission))
} else {
ktvApi.switchSingerRole(KTVSingRole.Audience, null)
tvSinger.text = getString(R.string.app_audience)
- KeyCenter.role = KTVSingRole.Audience
toast("退出合唱成功")
+ btJoinChorus.isActivated = false
}
}
// 开原唱:仅领唱和合唱者可以做这项操作
btOriginal.setOnClickListener {
ktvApi.switchAudioTrack(AudioTrackMode.YUAN_CHANG)
+
+ btOriginal.isActivated = true
+ btAcc.isActivated = false
+ btDaoChang.isActivated = false
}
// 开伴奏:仅领唱和合唱者可以做这项操作
btAcc.setOnClickListener {
ktvApi.switchAudioTrack(AudioTrackMode.BAN_ZOU)
+
+ btOriginal.isActivated = false
+ btAcc.isActivated = true
+ btDaoChang.isActivated = false
}
// 开导唱:仅领唱可以做这项操作,开启后领唱本地听到歌曲原唱,但观众听到仍为伴奏
btDaoChang.setOnClickListener {
ktvApi.switchAudioTrack(AudioTrackMode.DAO_CHANG)
+
+ btOriginal.isActivated = false
+ btAcc.isActivated = false
+ btDaoChang.isActivated = true
}
// 加载音乐
btLoadMusic.setOnClickListener {
- if (KeyCenter.isMcc) {
- // 使用声网版权中心歌单
- val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
- KeyCenter.LeadSingerUid,
- if (KeyCenter.role == KTVSingRole.Audience) KTVLoadMusicMode.LOAD_LRC_ONLY else KTVLoadMusicMode.LOAD_MUSIC_AND_LRC
- )
- ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener {
- override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
- Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
- ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
-
- // 加载成功开始播放音乐
- ktvApi.startSing(KeyCenter.songCode, 0)
- }
-
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
-
- }
- })
- } else if (KeyCenter.role == KTVSingRole.CoSinger) {
- ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
-
- }
-
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
-
- }
- })
- }
- }
-
- override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) {
- Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason")
- }
-
- override fun onMusicLoadProgress(
- songCode: Long,
- percent: Int,
- status: MusicLoadStatus,
- msg: String?,
- lyricUrl: String?
- ) {
- Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent")
- mainHandler.post {
- binding?.btLoadProgress?.text = "下载进度:$percent%"
- }
- }
- })
- } else {
- // 使用本地音乐文件
- val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
- KeyCenter.LeadSingerUid,
- KTVLoadMusicMode.LOAD_NONE
- )
- val songPath = requireActivity().filesDir.absolutePath + File.separator
- val songName = "不如跳舞"
- ktvApi.loadMusic("$songPath$songName.mp4", musicConfiguration)
- val fileLrc = File("$songPath$songName.xml")
- val lyricsModel = KaraokeView.parseLyricsData(fileLrc)
- karaokeView?.lyricsData = lyricsModel
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
- ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
- ktvApi.startSing("$songPath$songName.mp4", 0)
- }
-
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
-
- }
- })
- } else if (KeyCenter.role == KTVSingRole.CoSinger) {
- ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
- override fun onSwitchRoleSuccess() {
-
- }
-
- override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
-
- }
- })
- }
- }
+ loadMusic()
+ btLoadMusic.isActivated = true
+ btRemoveMusic.isActivated = false
}
// 取消加载歌曲并删除本地歌曲缓存
btRemoveMusic.setOnClickListener {
- if (KeyCenter.isMcc) {
- ktvApi.removeMusic(KeyCenter.songCode)
- lyricsView.reset()
- } else {
- toast(getString(R.string.app_no_premission))
- }
+ // 模拟移除
+ ktvApi.switchSingerRole(KTVSingRole.Audience, null)
+ btLoadMusic.isActivated = false
+ btRemoveMusic.isActivated = true
+ lyricsView.reset()
+ isLyricDataSet = false
}
// 开麦
btMicOn.setOnClickListener {
ktvApi.muteMic(false)
btMicStatus.text = "麦克风开"
+
+ btMicOn.isActivated = true
+ btMicOff.isActivated = false
}
// 关麦
btMicOff.setOnClickListener {
ktvApi.muteMic(true)
btMicStatus.text = "麦克风关"
+
+ btMicOn.isActivated = false
+ btMicOff.isActivated = true
}
// 设置麦克风初始状态
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
btMicStatus.text = "麦克风开"
} else {
btMicStatus.text = "麦克风关"
}
+
+ btPause.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ ktvApi.pauseSing()
+ btPlay.isActivated = false
+ btPause.isActivated = true
+ } else {
+ toast(getString(R.string.app_no_premission))
+ }
+ }
+
+ btPlay.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ btPlay.isActivated = true
+ btPause.isActivated = false
+ ktvApi.resumeSing()
+ } else {
+ toast(getString(R.string.app_no_premission))
+ }
+ }
}
}
+ // 防止歌词没设置,直接 set pitch
+ @Volatile
+ private var isLyricDataSet = false
+
/*
* 初始化 KTVAPI
*/
private fun initKTVApi() {
+ // ------------------ 初始化内容中心 ------------------
+ KTVApi.debugMode = true
+ KTVApi.mccDomain = "api-test.agora.io"
if (KeyCenter.isNormalChorus) {
// 创建普通合唱ktvapi实例
ktvApi = createKTVApi(
@@ -375,7 +328,7 @@ class LivingFragment : BaseFragment() {
chorusChannelToken = RtcEngineController.chorusChannelRtcToken,
maxCacheSize = 10,
type = KTVType.Normal,
- musicType = if (KeyCenter.isMcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL
+ musicType = if (KeyCenter.songSourceType == SongSourceType.Mcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL
)
)
} else {
@@ -393,7 +346,7 @@ class LivingFragment : BaseFragment() {
musicStreamUid = 2023,
musicStreamToken = RtcEngineController.musicStreamToken,
maxCacheSize = 10,
- musicType = if (KeyCenter.isMcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL
+ musicType = if (KeyCenter.songSourceType == SongSourceType.Mcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL
)
)
}
@@ -401,7 +354,11 @@ class LivingFragment : BaseFragment() {
ktvApi.addEventHandler(ktvApiEventHandler)
// 设置歌词组件
ktvApi.setLrcView(object : ILrcView {
+
override fun onUpdatePitch(pitch: Float?) {
+ pitch?.let {
+ karaokeView?.setPitch(it)
+ }
}
override fun onUpdateProgress(progress: Long?) {
@@ -428,10 +385,11 @@ class LivingFragment : BaseFragment() {
val channelMediaOptions = ChannelMediaOptions().apply {
autoSubscribeAudio = true
autoSubscribeVideo = true
- autoSubscribeAudio = true
publishCameraTrack = false
- publishMicrophoneTrack = KeyCenter.role != KTVSingRole.Audience
- clientRoleType = if (KeyCenter.role == KTVSingRole.Audience) io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE else io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER
+ publishMicrophoneTrack = KeyCenter.isBroadcaster
+ clientRoleType =
+ if (KeyCenter.isBroadcaster) io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER
+ else io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE
}
if (KeyCenter.isNormalChorus) {
@@ -443,6 +401,7 @@ class LivingFragment : BaseFragment() {
channelMediaOptions
)
} else {
+ // 大合唱加入频道
// 大合唱加入频道
RtcEngineController.rtcEngine.joinChannelEx(
RtcEngineController.audienceChannelToken,
@@ -459,7 +418,10 @@ class LivingFragment : BaseFragment() {
}
}
)
- RtcEngineController.rtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid))
+ RtcEngineController.rtcEngine.setParametersEx(
+ "{\"rtc.use_audio4\": true}",
+ RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid)
+ )
}
// 加入频道后需要更新数据传输通道
@@ -470,22 +432,24 @@ class LivingFragment : BaseFragment() {
* 加载、播放音乐
*/
private fun loadMusic() {
- if (KeyCenter.isMcc) {
+ if (KeyCenter.songSourceType == SongSourceType.Mcc) {
+ // 使用声网版权中心歌单
+ val songCode = KeyCenter.mccSongCode
// 使用声网版权中心歌单
val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
+ songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
KeyCenter.LeadSingerUid,
- if (KeyCenter.role == KTVSingRole.Audience) KTVLoadMusicMode.LOAD_LRC_ONLY else KTVLoadMusicMode.LOAD_MUSIC_AND_LRC
+ if (KeyCenter.isBroadcaster) KTVLoadMusicMode.LOAD_MUSIC_AND_LRC else KTVLoadMusicMode.LOAD_LRC_ONLY,
)
- ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener {
+ ktvApi.loadMusic(songCode, musicConfiguration, object : IMusicLoadStateListener {
override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener {
override fun onSwitchRoleSuccess() {
// 加载成功开始播放音乐
- ktvApi.startSing(KeyCenter.songCode, 0)
+ ktvApi.startSing(songCode, 0)
}
override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
@@ -515,7 +479,7 @@ class LivingFragment : BaseFragment() {
} else {
// 使用本地音乐文件
val musicConfiguration = KTVLoadMusicConfiguration(
- KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
+ KeyCenter.mccSongCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
KeyCenter.LeadSingerUid,
KTVLoadMusicMode.LOAD_NONE
)
@@ -525,7 +489,7 @@ class LivingFragment : BaseFragment() {
val fileLrc = File("$songPath$songName.xml")
val lyricsModel = KaraokeView.parseLyricsData(fileLrc)
karaokeView?.lyricsData = lyricsModel
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
+ if (KeyCenter.isBroadcaster) {
ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener {
override fun onSwitchRoleSuccess() {
ktvApi.startSing("$songPath$songName.mp4", 0)
@@ -537,58 +501,32 @@ class LivingFragment : BaseFragment() {
})
}
}
- }
-
- private fun dealDownloadLrc(url: String) {
- DownloadUtils.getInstance().download(requireContext(), url, object : DownloadUtils.FileDownloadCallback {
- override fun onSuccess(file: File) {
- if (file.name.endsWith(".zip")) {
- ZipUtils.unzipOnlyPlainXmlFilesAsync(file.absolutePath,
- file.absolutePath.replace(".zip", ""),
- object : ZipUtils.UnZipCallback {
- override fun onFileUnZipped(unZipFilePaths: MutableList) {
- var xmlPath = ""
- for (path in unZipFilePaths) {
- if (path.endsWith(".xml")) {
- xmlPath = path
- break
- }
- }
- if (TextUtils.isEmpty(xmlPath)) {
- toast("The xml file not exist!")
- return
- }
-
- val xmlFile = File(xmlPath)
- val lyricsModel = KaraokeView.parseLyricsData(xmlFile)
-
- if (lyricsModel == null) {
- toast("Unexpected content from $xmlPath")
- return
- }
- karaokeView?.lyricsData = lyricsModel
- }
-
- override fun onError(e: Exception?) {
- toast(e?.message ?: "UnZip xml error")
- }
+ }
+ private fun dealDownloadLrc(lrcUrl: String) {
+ val context: Context = MyApplication.app()
+ LyricsFileDownloader.getInstance(context)
+ .setLyricsFileDownloaderCallback(object : LyricsFileDownloaderCallback {
+ override fun onLyricsFileDownloadProgress(requestId: Int, progress: Float) {}
+
+ override fun onLyricsFileDownloadCompleted(requestId: Int, fileData: ByteArray, error: DownloadError?) {
+ if (error == null) {
+ val lyricsModel = KaraokeView.parseLyricsData(fileData)
+ if (lyricsModel == null) {
+ Toast.makeText(context, "Unexpected parseLyricsData", Toast.LENGTH_SHORT).show()
+ return
+ }
+ karaokeView?.let {
+ karaokeView?.lyricsData = lyricsModel
+ }
+ } else {
+ error.message?.let {
+ Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
- )
- } else {
- val lyricsModel = KaraokeView.parseLyricsData(file)
- if (lyricsModel == null) {
- toast("Unexpected content from $file")
- return
}
- karaokeView?.lyricsData = lyricsModel
}
- }
-
- override fun onFailed(exception: Exception?) {
- toast("download lrc ${exception?.message}")
- }
- })
+ })
+ LyricsFileDownloader.getInstance(context).download(lrcUrl)
}
}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragmentEx.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragmentEx.kt
new file mode 100644
index 0000000..aaa7e42
--- /dev/null
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragmentEx.kt
@@ -0,0 +1,531 @@
+package io.agora.ktvdemo.ui
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.fragment.findNavController
+import io.agora.karaoke_view_ex.KaraokeView
+import io.agora.ktvapiex.*
+import io.agora.ktvdemo.BuildConfig
+import io.agora.ktvdemo.R
+import io.agora.ktvdemo.api.CloudApiManager
+import io.agora.ktvdemo.databinding.FragmentLivingExBinding
+import io.agora.ktvdemo.rtc.RtcEngineController
+import io.agora.ktvdemo.utils.KeyCenter
+import io.agora.ktvdemo.utils.TokenGenerator
+import io.agora.mccex.IMusicContentCenterEx
+import io.agora.mccex.MusicContentCenterExConfiguration
+import io.agora.mccex.constants.ChargeMode
+import io.agora.mccex.constants.MccExState
+import io.agora.mccex.model.LineScoreData
+import io.agora.mccex.model.YsdVendorConfigure
+import io.agora.rtc2.ChannelMediaOptions
+import io.agora.rtc2.IRtcEngineEventHandler
+import io.agora.rtc2.RtcConnection
+import java.io.File
+import java.util.concurrent.Executors
+
+/*
+ * K 歌体验页面
+ */
+class LivingFragmentEx : BaseFragment() {
+
+ /*
+ * 歌词组件的 view
+ */
+ private var karaokeView: KaraokeView? = null
+
+ /*
+ * KTVAPI 实例
+ */
+ private lateinit var ktvApi: KTVApi
+
+ /*
+ * KTVAPI 事件
+ */
+ private val ktvApiEventHandler = object : IKTVApiEventHandler() {}
+
+ private val scheduledThreadPool = Executors.newScheduledThreadPool(5)
+
+ override fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLivingExBinding {
+ return FragmentLivingExBinding.inflate(inflater)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ ViewCompat.setOnApplyWindowInsetsListener(view) { v: View?, insets: WindowInsetsCompat ->
+ val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPaddingRelative(inset.left, 0, inset.right, 0)
+ WindowInsetsCompat.CONSUMED
+ }
+
+ val sceneName = if (KeyCenter.isNormalChorus) getString(R.string.app_normal_ktvapi_tag)
+ else getString(R.string.app_giant_ktvapi_tag)
+ binding?.tvChorusScene?.text = "$sceneName Channel:${KeyCenter.channelId} MccEx"
+
+ // 大合唱模式下主唱需要启动云端合流
+ if (KeyCenter.isBroadcaster && !KeyCenter.isNormalChorus) {
+ TokenGenerator.generateToken("${KeyCenter.channelId}_ad", CloudApiManager.outputUid.toString(),
+ TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc,
+ success = { outputToken ->
+ // uid
+ val inputRtcUid = 0
+ TokenGenerator.generateToken(KeyCenter.channelId, inputRtcUid.toString(),
+ TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc,
+ success = { inputToken ->
+ scheduledThreadPool.execute {
+ CloudApiManager.getInstance().fetchStartCloud(
+ KeyCenter.channelId,
+ inputRtcUid,
+ inputToken,
+ outputToken
+ )
+ }
+ },
+ failure = {
+ toast("云端合流启动失败, token获取失败")
+ }
+ )
+ },
+ failure = {
+ toast("云端合流启动失败, token获取失败")
+ }
+ )
+ }
+
+ initView()
+ initKTVApi()
+ joinChannel()
+
+ // 设置麦克风初始状态,主唱默认开麦
+ if (KeyCenter.isBroadcaster) {
+ ktvApi.muteMic(false)
+ }
+// loadMusic()
+ }
+
+ override fun onDestroy() {
+ ktvApi.switchSingerRole(KTVSingRole.Audience, null)
+ ktvApi.removeEventHandler(ktvApiEventHandler)
+ ktvApi.release()
+ RtcEngineController.rtcEngine.leaveChannel()
+ super.onDestroy()
+ }
+
+ private fun initView() {
+ binding?.apply {
+ karaokeView = KaraokeView(lyricsView, scoringView)
+
+ // 退出场景
+ btnClose.setOnClickListener {
+ ktvApi.switchSingerRole(KTVSingRole.Audience, null)
+ ktvApi.removeEventHandler(ktvApiEventHandler)
+ ktvApi.release()
+ RtcEngineController.rtcEngine.leaveChannel()
+ if (KeyCenter.isBroadcaster && !KeyCenter.isNormalChorus) {
+ scheduledThreadPool.execute {
+ CloudApiManager.getInstance().fetchStopCloud()
+ }
+ }
+ findNavController().popBackStack()
+ }
+ if (KeyCenter.isBroadcaster) {
+ tvSinger.text = getString(R.string.app_lead_singer)
+ } else {
+ tvSinger.text = getString(R.string.app_audience)
+ }
+
+ // 加入合唱
+ btJoinChorus.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ toast(getString(R.string.app_no_premission))
+ } else {
+ val songCode = KeyCenter.mccExSongCode
+ // 使用声网版权中心歌单
+ val musicConfiguration = KTVLoadMusicConfiguration(
+ songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
+ KeyCenter.LeadSingerUid,
+ KTVLoadMusicMode.LOAD_MUSIC_ONLY,
+ false,
+ needPitch = true
+ )
+ ktvApi.loadMusic(songCode, musicConfiguration, object : IMusicLoadStateListener {
+ override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
+ Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
+ ktvApi.startScore(songCode) { _, state, _ ->
+ if (state == MccExState.START_SCORE_STATE_COMPLETED) {
+ // 切换身份为合唱者
+ ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener {
+ override fun onSwitchRoleSuccess() {
+ mainHandler.post {
+ toast("加入合唱成功,自动开麦")
+ ktvApi.muteMic(false)
+ btMicStatus.text = "麦克风开"
+ tvSinger.text = getString(R.string.app_co_singer)
+ btJoinChorus.isActivated = true
+ btMicOn.isActivated = true
+ btMicOff.isActivated = false
+ }
+ }
+
+ override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
+ mainHandler.post {
+ toast("加入合唱失败")
+ btJoinChorus.isActivated = false
+ }
+ }
+ })
+ }
+ }
+
+ }
+
+ override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) {
+ Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason")
+ }
+
+ override fun onMusicLoadProgress(
+ songCode: Long,
+ percent: Int,
+ status: MusicLoadStatus,
+ msg: String?,
+ lyricUrl: String?
+ ) {
+ Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent")
+ mainHandler.post {
+ binding?.btLoadProgress?.text = "下载进度:$percent%"
+ }
+ }
+ })
+ }
+ }
+
+ // 退出合唱
+ btLeaveChorus.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ toast(getString(R.string.app_no_premission))
+ } else {
+ ktvApi.switchSingerRole(KTVSingRole.Audience, null)
+ tvSinger.text = getString(R.string.app_audience)
+ toast("退出合唱成功")
+ btJoinChorus.isActivated = false
+ }
+ }
+
+ // 开原唱:仅领唱和合唱者可以做这项操作
+ btOriginal.setOnClickListener {
+ ktvApi.switchAudioTrack(AudioTrackMode.YUAN_CHANG)
+
+ btOriginal.isActivated = true
+ btAcc.isActivated = false
+ btDaoChang.isActivated = false
+ }
+
+ // 开伴奏:仅领唱和合唱者可以做这项操作
+ btAcc.setOnClickListener {
+ ktvApi.switchAudioTrack(AudioTrackMode.BAN_ZOU)
+
+ btOriginal.isActivated = false
+ btAcc.isActivated = true
+ btDaoChang.isActivated = false
+ }
+
+ // 开导唱:仅领唱可以做这项操作,开启后领唱本地听到歌曲原唱,但观众听到仍为伴奏
+ btDaoChang.setOnClickListener {
+ ktvApi.switchAudioTrack(AudioTrackMode.DAO_CHANG)
+
+ btOriginal.isActivated = false
+ btAcc.isActivated = false
+ btDaoChang.isActivated = true
+ }
+
+ // 加载音乐
+ btLoadMusic.setOnClickListener {
+ loadMusic()
+ btLoadMusic.isActivated = true
+ btRemoveMusic.isActivated = false
+ }
+
+ // 取消加载歌曲并删除本地歌曲缓存
+ btRemoveMusic.setOnClickListener {
+ // 模拟移除
+ ktvApi.switchSingerRole(KTVSingRole.Audience, null)
+ btLoadMusic.isActivated = false
+ btRemoveMusic.isActivated = true
+ lyricsView.reset()
+ isLyricDataSet = false
+ }
+
+ // 开麦
+ btMicOn.setOnClickListener {
+ ktvApi.muteMic(false)
+ btMicStatus.text = "麦克风开"
+
+ btMicOn.isActivated = true
+ btMicOff.isActivated = false
+ }
+
+ // 关麦
+ btMicOff.setOnClickListener {
+ ktvApi.muteMic(true)
+ btMicStatus.text = "麦克风关"
+
+ btMicOn.isActivated = false
+ btMicOff.isActivated = true
+ }
+
+ // 设置麦克风初始状态
+ if (KeyCenter.isBroadcaster) {
+ btMicStatus.text = "麦克风开"
+ } else {
+ btMicStatus.text = "麦克风关"
+ }
+
+ btPause.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ ktvApi.pauseSing()
+ btPlay.isActivated = false
+ btPause.isActivated = true
+ } else {
+ toast(getString(R.string.app_no_premission))
+ }
+ }
+
+ btPlay.setOnClickListener {
+ if (KeyCenter.isBroadcaster) {
+ btPlay.isActivated = true
+ btPause.isActivated = false
+ ktvApi.resumeSing()
+ } else {
+ toast(getString(R.string.app_no_premission))
+ }
+ }
+ }
+ }
+
+ // 防止歌词没设置,直接 set pitch
+ @Volatile
+ private var isLyricDataSet = false
+
+ /*
+ * 初始化 KTVAPI
+ */
+ private fun initKTVApi() {
+ // ------------------ 初始化内容中心 ------------------
+ val contentCenterConfiguration = MusicContentCenterExConfiguration()
+ contentCenterConfiguration.context = context
+ contentCenterConfiguration.vendorConfigure = YsdVendorConfigure(
+ appId = BuildConfig.EX_APP_ID,
+ appKey = BuildConfig.EX_APP_Key,
+ token = BuildConfig.EX_APP_TOKEN,
+ userId = BuildConfig.EX_USERID,
+ deviceId = "2323",
+ chargeMode = ChargeMode.ONCE,
+ urlTokenExpireTime = 60 * 15
+ )
+ contentCenterConfiguration.enableLog = true
+ contentCenterConfiguration.enableSaveLogToFile = true
+ contentCenterConfiguration.logFilePath = context?.getExternalFilesDir(null)?.path
+
+ val mMusicCenter = IMusicContentCenterEx.create(RtcEngineController.rtcEngine)!!
+ mMusicCenter.initialize(contentCenterConfiguration)
+
+ if (KeyCenter.isNormalChorus) {
+ ktvApi = KTVApiImpl()
+ val ktvApiConfig = KTVApiConfig(
+ BuildConfig.AGORA_APP_ID,
+ mMusicCenter,
+ RtcEngineController.rtcEngine,
+ KeyCenter.channelId, // 演唱频道channelId
+ KeyCenter.localUid, // uid
+ KeyCenter.channelId + "_ex", // 子频道名
+ RtcEngineController.chorusChannelRtcToken,
+ 10,
+ KTVType.Normal,
+ KTVMusicType.SONG_CODE,
+ )
+ ktvApi.initialize(ktvApiConfig)
+ } else {
+ ktvApi = KTVGiantChorusApiImpl()
+ val ktvApiConfig = KTVGiantChorusApiConfig(
+ BuildConfig.AGORA_APP_ID,
+ mMusicCenter,
+ RtcEngineController.rtcEngine,
+ KeyCenter.localUid, // uid
+ audienceChannelName = KeyCenter.channelId + "_ad", // 观众频道channelId
+ audienceChannelToken = RtcEngineController.audienceChannelToken, // 观众频道channelId + uid = 加入观众频道的token
+ chorusChannelName = KeyCenter.channelId, // 演唱频道channelId
+ chorusChannelToken = RtcEngineController.chorusChannelRtcToken, // 演唱频道channelId + uid = 加入演唱频道的token
+ musicStreamUid = 2023, // mpk uid
+ musicStreamToken = RtcEngineController.musicStreamToken, // 演唱频道channelId + mpk uid = mpk 流加入频道的token
+ maxCacheSize = 10,
+ musicType = KTVMusicType.SONG_CODE
+ )
+ ktvApi.initialize(ktvApiConfig)
+ }
+ // 注册 ktvapi 事件
+ ktvApi.addEventHandler(ktvApiEventHandler)
+ // 设置歌词组件
+ ktvApi.setLrcView(object : ILrcView {
+
+ override fun onUpdateProgress(progress: Long?) {
+ karaokeView?.setProgress(progress ?: 0L)
+ }
+
+ override fun onUpdatePitch(songCode: Long, pitch: Double, progressInMs: Int) {
+ if (isLyricDataSet) {
+ karaokeView?.setPitch(pitch.toFloat(), progressInMs)
+ }
+ }
+
+ override fun onLineScore(songCode: Long, value: LineScoreData) {
+
+ }
+
+ override fun onDownloadLrcData(lyricPath: String?, pitchPath: String?) {
+ lyricPath?.let { lrc ->
+ val mLyricsModel = if (pitchPath.isNullOrEmpty())
+ KaraokeView.parseLyricData(File(lrc), null)
+ else KaraokeView.parseLyricData(File(lrc), File(pitchPath))
+
+ mLyricsModel?.let { lyricModel ->
+ karaokeView?.setLyricData(lyricModel)
+ isLyricDataSet = true
+ }
+ }
+ }
+ })
+ }
+
+ /*
+ * 加入 RTC 频道
+ */
+ private fun joinChannel() {
+ val channelMediaOptions = ChannelMediaOptions().apply {
+ autoSubscribeAudio = true
+ autoSubscribeVideo = true
+ publishCameraTrack = false
+ publishMicrophoneTrack = KeyCenter.isBroadcaster
+ clientRoleType =
+ if (KeyCenter.isBroadcaster) io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER
+ else io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE
+ }
+
+ if (KeyCenter.isNormalChorus) {
+ // 普通合唱或独唱加入频道
+ RtcEngineController.rtcEngine.joinChannel(
+ RtcEngineController.audienceChannelToken,
+ KeyCenter.channelId,
+ KeyCenter.localUid,
+ channelMediaOptions
+ )
+ } else {
+ // 大合唱加入频道
+ if (!KeyCenter.isBroadcaster) {
+ val channelMediaOptions = ChannelMediaOptions().apply {
+ autoSubscribeAudio = true
+ clientRoleType = io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE
+ autoSubscribeVideo = true
+ autoSubscribeAudio = true
+ publishCameraTrack = false
+ publishMicrophoneTrack = false
+ }
+ RtcEngineController.rtcEngine.joinChannelEx(
+ RtcEngineController.audienceChannelToken,
+ RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid),
+ channelMediaOptions,
+ object : IRtcEngineEventHandler() {
+ override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+// (ktvApi as KTVGiantChorusApiImpl).setAudienceStreamMessage(uid, streamId, data)
+ }
+
+ override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) {
+ super.onAudioMetadataReceived(uid, data)
+ (ktvApi as KTVGiantChorusApiImpl).setAudienceAudioMetadataReceived(uid, data)
+ }
+ }
+ )
+ } else {
+ // 主唱加入演唱频道
+ val channelMediaOptions = ChannelMediaOptions().apply {
+ autoSubscribeAudio = true
+ clientRoleType = io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER
+ autoSubscribeVideo = true
+ autoSubscribeAudio = true
+ publishCameraTrack = false
+ publishMicrophoneTrack = true
+ }
+ RtcEngineController.rtcEngine.joinChannel(
+ RtcEngineController.chorusChannelRtcToken,
+ KeyCenter.channelId, KeyCenter.localUid,
+ channelMediaOptions
+ )
+ }
+ RtcEngineController.rtcEngine.setParametersEx(
+ "{\"rtc.use_audio4\": true}",
+ RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid)
+ )
+ }
+
+ // 加入频道后需要更新数据传输通道
+ ktvApi.renewInnerDataStreamId()
+ }
+
+ /*
+ * 加载、播放音乐
+ */
+ private fun loadMusic() {
+ val songCode = KeyCenter.mccExSongCode
+ // 使用声网版权中心歌单
+ val musicConfiguration = KTVLoadMusicConfiguration(
+ songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode
+ KeyCenter.LeadSingerUid,
+ if (KeyCenter.isBroadcaster) KTVLoadMusicMode.LOAD_MUSIC_AND_LRC
+ else KTVLoadMusicMode.LOAD_LRC_ONLY,
+ needPitch = true
+ )
+ ktvApi.loadMusic(songCode, musicConfiguration, object : IMusicLoadStateListener {
+ override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) {
+ Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl")
+ ktvApi.startScore(songCode) { _, state, _ ->
+ if (state == MccExState.START_SCORE_STATE_COMPLETED) {
+ if (KeyCenter.isBroadcaster) {
+ ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener {
+ override fun onSwitchRoleSuccess() {
+
+ // 加载成功开始播放音乐
+ ktvApi.startSing(songCode, 0)
+ }
+
+ override fun onSwitchRoleFail(reason: SwitchRoleFailReason) {
+
+ }
+ })
+ }
+ }
+ }
+ }
+
+ override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) {
+ Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason")
+ }
+
+ override fun onMusicLoadProgress(
+ songCode: Long,
+ percent: Int,
+ status: MusicLoadStatus,
+ msg: String?,
+ lyricUrl: String?
+ ) {
+ Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent")
+ mainHandler.post {
+ binding?.btLoadProgress?.text = "下载进度:$percent%"
+ }
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainActivity.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainActivity.kt
index 8b5f49e..7cc421a 100644
--- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainActivity.kt
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainActivity.kt
@@ -2,8 +2,12 @@ package io.agora.ktvdemo.ui
import android.Manifest
import android.content.pm.PackageManager
+import android.graphics.Color
+import android.os.Build
import android.os.Bundle
import android.util.Log
+import android.view.View
+import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import io.agora.ktvdemo.databinding.ActivityMainBinding
@@ -22,8 +26,10 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
+ setDarkStatusIcon(true)
ActivityCompat.requestPermissions(this, PERMISSIONS, 100)
}
@@ -42,4 +48,23 @@ class MainActivity : AppCompatActivity() {
}
}
}
+
+ fun setDarkStatusIcon(bDark: Boolean) {
+ //5.x开始需要把颜色设置透明,否则导航栏会呈现系统默认的浅灰色
+ val decorView = window.decorView
+ //两个 flag 要结合使用,表示让应用的主体内容占用系统状态栏的空间
+ var option = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION //| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION//| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
+ window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ //在6.0增加了View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR,
+ // 这个字段就是把状态栏标记为浅色,然后状态栏的字体颜色自动转换为深色
+ if (bDark) {
+ option = option or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
+ }
+ decorView.systemUiVisibility = option
+ window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ window.statusBarColor = Color.TRANSPARENT
+ }
}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt
index 38703a5..560e99f 100644
--- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt
@@ -4,7 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.doAfterTextChanged
import androidx.navigation.fragment.findNavController
import io.agora.ktvapi.KTVSingRole
@@ -14,6 +15,7 @@ import io.agora.ktvdemo.R
import io.agora.ktvdemo.rtc.RtcEngineController
import io.agora.ktvdemo.databinding.FragmentMainBinding
import io.agora.ktvdemo.utils.KeyCenter
+import io.agora.ktvdemo.utils.SongSourceType
import io.agora.ktvdemo.utils.TokenGenerator
import kotlin.random.Random
@@ -28,8 +30,18 @@ class MainFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ ViewCompat.setOnApplyWindowInsetsListener(view) { v: View?, insets: WindowInsetsCompat ->
+ val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPaddingRelative(inset.left, 0, inset.right, 0)
+ WindowInsetsCompat.CONSUMED
+ }
binding?.apply {
resetRoleView()
+ if (KeyCenter.isBroadcaster) {
+ KeyCenter.localUid = KeyCenter.LeadSingerUid
+ } else {
+ KeyCenter.localUid = Random(System.currentTimeMillis()).nextInt(100000) + 1000000
+ }
setRoleView()
// 频道名输入框,开始体验前需要输入一个频道名
@@ -40,7 +52,7 @@ class MainFragment : BaseFragment() {
// 初始角色选择主唱,一个体验频道只能有一个主唱
btnLeadSinger.setOnClickListener {
resetRoleView()
- KeyCenter.role = KTVSingRole.LeadSinger
+ KeyCenter.isBroadcaster = true
KeyCenter.localUid = KeyCenter.LeadSingerUid
setRoleView()
}
@@ -48,16 +60,33 @@ class MainFragment : BaseFragment() {
// 初始角色选择观众,一个体验频道可以有多个观众
btnAudience.setOnClickListener {
resetRoleView()
- KeyCenter.role = KTVSingRole.Audience
+ KeyCenter.isBroadcaster = false
KeyCenter.localUid = Random(System.currentTimeMillis()).nextInt(100000) + 1000000
setRoleView()
}
- // 选择加载歌曲的类型, MCC 声网歌曲中心或者本地歌曲
- groupSongType.setOnCheckedChangeListener { _, checkedId -> KeyCenter.isMcc = checkedId == R.id.rbtMccSong }
+ when (KeyCenter.songSourceType) {
+ SongSourceType.Local -> songSourceType.check(R.id.rbtLocalSong)
+ SongSourceType.Mcc -> songSourceType.check(R.id.rbtMccSong)
+ SongSourceType.MccEx -> songSourceType.check(R.id.rbtMccExSong)
+ }
+ songSourceType.setOnCheckedChangeListener { _, checkedId ->
+ KeyCenter.songSourceType = when (checkedId) {
+ R.id.rbtLocalSong -> SongSourceType.Local
+ R.id.rbtMccSong -> SongSourceType.Mcc
+ else -> SongSourceType.MccEx
+ }
+ }
- // 选择体验 KTVApi 的类型, 普通合唱或者大合唱
- ktvApiType.setOnCheckedChangeListener { _, checkedId -> KeyCenter.isNormalChorus = checkedId == R.id.rbtNormalChorus}
+ // 选择体验合唱场景, 普通合唱或者大合唱
+ if (KeyCenter.isNormalChorus) {
+ chorusScene.check(R.id.rbtNormalChorus)
+ } else {
+ chorusScene.check(R.id.rbtGiantChorus)
+ }
+ chorusScene.setOnCheckedChangeListener { _, checkedId ->
+ KeyCenter.isNormalChorus = checkedId == R.id.rbtNormalChorus
+ }
// 开始体验按钮
btnStartChorus.setOnClickListener {
@@ -65,11 +94,11 @@ class MainFragment : BaseFragment() {
toast(getString(R.string.app_appid_check))
return@setOnClickListener
}
-// if (!KeyCenter.isNormalChorus && BuildConfig.RESTFUL_API_KEY.isEmpty()) {
-// toast(getString(R.string.app_restful_check))
-// return@setOnClickListener
-// }
- if (KeyCenter.channelId.isEmpty()){
+ if (!KeyCenter.isNormalChorus && BuildConfig.RESTFUL_API_KEY.isEmpty()) {
+ toast(getString(R.string.app_restful_check))
+ return@setOnClickListener
+ }
+ if (KeyCenter.channelId.isEmpty()) {
toast(getString(R.string.app_input_channel_name))
return@setOnClickListener
}
@@ -97,7 +126,11 @@ class MainFragment : BaseFragment() {
RtcEngineController.audienceChannelToken = rtcToken
RtcEngineController.rtmToken = rtmToken
RtcEngineController.chorusChannelRtcToken = chorusToken
- findNavController().navigate(R.id.action_mainFragment_to_livingFragment)
+ if (KeyCenter.songSourceType==SongSourceType.MccEx) {
+ findNavController().navigate(R.id.action_mainFragment_to_livingFragmentEx)
+ } else {
+ findNavController().navigate(R.id.action_mainFragment_to_livingFragment)
+ }
},
failure = {
toast("获取 token 异常1")
@@ -114,7 +147,11 @@ class MainFragment : BaseFragment() {
RtcEngineController.rtmToken = rtmToken
RtcEngineController.audienceChannelToken = audienceToken
RtcEngineController.musicStreamToken = musicToken
- findNavController().navigate(R.id.action_mainFragment_to_livingFragment)
+ if (KeyCenter.songSourceType==SongSourceType.MccEx) {
+ findNavController().navigate(R.id.action_mainFragment_to_livingFragmentEx)
+ } else {
+ findNavController().navigate(R.id.action_mainFragment_to_livingFragment)
+ }
},
failure = {
toast("获取 token 异常2")
@@ -138,18 +175,15 @@ class MainFragment : BaseFragment() {
private fun resetRoleView() {
binding?.apply {
- btnLeadSinger.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.lighter_gray, null))
- btnAudience.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.lighter_gray, null))
+ btnLeadSinger.isActivated = false
+ btnAudience.isActivated = false
}
}
private fun setRoleView() {
binding?.apply {
- if (KeyCenter.role == KTVSingRole.LeadSinger) {
- btnLeadSinger.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.darker_gray, null))
- } else {
- btnAudience.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.darker_gray, null))
- }
+ btnLeadSinger.isActivated = KeyCenter.isBroadcaster
+ btnAudience.isActivated = !KeyCenter.isBroadcaster
}
}
}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt
index 25bc09d..3118f90 100644
--- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt
+++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt
@@ -1,7 +1,5 @@
package io.agora.ktvdemo.utils
-import io.agora.ktvapi.KTVSingRole
-
object KeyCenter {
/*
@@ -9,10 +7,8 @@ object KeyCenter {
*/
const val LeadSingerUid = 2024
- /*
- * 测试歌曲的 songCode
- */
- const val songCode: Long = 7162848697922600
+ val mccSongCode: Long get() = 6625526605291650 // 6654550265524810
+ val mccExSongCode: Long get() = 89488966 //40289835
/*
* 加入的频道名
@@ -24,11 +20,6 @@ object KeyCenter {
*/
var localUid: Int = 2024
- /*
- * 选择的歌曲类型
- */
- var isMcc: Boolean = true
-
/*
* 体验 KTVAPI 的类型, true为普通合唱、false为大合唱
*/
@@ -37,5 +28,21 @@ object KeyCenter {
/*
* 当前演唱中的身份
*/
- var role: KTVSingRole = KTVSingRole.LeadSinger
+ var isBroadcaster: Boolean = false
+
+ /**
+ * 歌曲来源
+ */
+ var songSourceType: SongSourceType = SongSourceType.Local
+}
+
+/**
+ * Song source type
+ *
+ * @constructor Create empty Song source type
+ */
+enum class SongSourceType{
+ Local, // 本地歌曲
+ Mcc, // Agora MCC
+ MccEx // Agora MCC EX
}
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/color/button_color_selector.xml b/KTVAPI/Android/app/src/main/res/color/button_color_selector.xml
new file mode 100644
index 0000000..ffa5efe
--- /dev/null
+++ b/KTVAPI/Android/app/src/main/res/color/button_color_selector.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/layout/fragment_living.xml b/KTVAPI/Android/app/src/main/res/layout/fragment_living.xml
index 16b48a5..c802474 100644
--- a/KTVAPI/Android/app/src/main/res/layout/fragment_living.xml
+++ b/KTVAPI/Android/app/src/main/res/layout/fragment_living.xml
@@ -3,7 +3,22 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:layout_height="match_parent">
+ android:layout_height="match_parent"
+ android:background="@mipmap/bg_app_def_white">
+
+
+
+
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/lyricsView" />
+ app:layout_constraintStart_toEndOf="@+id/btLoadMusic"
+ app:layout_constraintTop_toBottomOf="@+id/lyricsView" />
+ android:layout_marginStart="12dp"
+ app:layout_constraintBottom_toBottomOf="@+id/btRemoveMusic"
+ app:layout_constraintStart_toEndOf="@+id/btRemoveMusic"
+ app:layout_constraintTop_toTopOf="@+id/btRemoveMusic" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/btLoadMusic" />
+ app:layout_constraintStart_toEndOf="@+id/btJoinChorus"
+ app:layout_constraintTop_toBottomOf="@+id/btLoadMusic" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/btJoinChorus" />
+ app:layout_constraintStart_toEndOf="@+id/btOriginal"
+ app:layout_constraintTop_toBottomOf="@+id/btJoinChorus" />
+ app:layout_constraintStart_toEndOf="@+id/btAcc"
+ app:layout_constraintTop_toBottomOf="@+id/btJoinChorus" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/btOriginal" />
+ app:layout_constraintStart_toEndOf="@+id/btMicOn"
+ app:layout_constraintTop_toBottomOf="@+id/btOriginal" />
+ android:layout_marginStart="12dp"
+ app:layout_constraintBottom_toBottomOf="@+id/btMicOff"
+ app:layout_constraintStart_toEndOf="@+id/btMicOff"
+ app:layout_constraintTop_toTopOf="@+id/btMicOff" />
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/layout/fragment_living_ex.xml b/KTVAPI/Android/app/src/main/res/layout/fragment_living_ex.xml
new file mode 100644
index 0000000..c86aa5e
--- /dev/null
+++ b/KTVAPI/Android/app/src/main/res/layout/fragment_living_ex.xml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml b/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml
index e548c44..6b70d65 100644
--- a/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml
+++ b/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml
@@ -2,7 +2,8 @@
+ android:layout_height="match_parent"
+ android:background="@mipmap/bg_app_def_white">
+ android:text="@string/app_normal_ktvapi_tag" />
+ android:text="@string/app_giant_ktvapi_tag" />
+ app:layout_constraintTop_toBottomOf="@id/chorusScene">
+ android:text="Local" />
+
+
+ android:text="MccEx" />
@@ -123,5 +131,5 @@
android:textColor="@color/black"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/ktvApiType" />
+ app:layout_constraintTop_toBottomOf="@id/songSourceType" />
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-anydpi-v26/app_ktv_ic_launcher.xml b/KTVAPI/Android/app/src/main/res/mipmap-anydpi-v26/app_ktv_ic_launcher.xml
new file mode 100644
index 0000000..50c0360
--- /dev/null
+++ b/KTVAPI/Android/app/src/main/res/mipmap-anydpi-v26/app_ktv_ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher.png b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher.png
deleted file mode 100644
index 5639fbf..0000000
Binary files a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher.png and /dev/null differ
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_ktv.png b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_ktv.png
deleted file mode 100644
index 4130487..0000000
Binary files a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_ktv.png and /dev/null differ
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_round.png b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_round.png
deleted file mode 100644
index 13f2f2a..0000000
Binary files a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ic_launcher_round.png and /dev/null differ
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher.webp b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher.webp
new file mode 100644
index 0000000..c78d8a1
Binary files /dev/null and b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher.webp differ
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher_foreground.webp b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher_foreground.webp
new file mode 100644
index 0000000..12f369b
Binary files /dev/null and b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/app_ktv_ic_launcher_foreground.webp differ
diff --git a/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/bg_app_def_white.png b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/bg_app_def_white.png
new file mode 100644
index 0000000..ed4444d
Binary files /dev/null and b/KTVAPI/Android/app/src/main/res/mipmap-xxxhdpi/bg_app_def_white.png differ
diff --git a/KTVAPI/Android/app/src/main/res/navigation/app_navigation.xml b/KTVAPI/Android/app/src/main/res/navigation/app_navigation.xml
index f837c9f..43a06ac 100644
--- a/KTVAPI/Android/app/src/main/res/navigation/app_navigation.xml
+++ b/KTVAPI/Android/app/src/main/res/navigation/app_navigation.xml
@@ -16,6 +16,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/values-zh/strings.xml b/KTVAPI/Android/app/src/main/res/values-zh/strings.xml
index 51ca264..457e94a 100644
--- a/KTVAPI/Android/app/src/main/res/values-zh/strings.xml
+++ b/KTVAPI/Android/app/src/main/res/values-zh/strings.xml
@@ -1,6 +1,6 @@
- KTVAPI-Demo
+ KtvApi-Demo
主唱
合唱
观众
@@ -18,6 +18,7 @@
请选择初始演唱身份!
请输入 channel name
MCC 声网歌曲中心
+ MCCEX 歌曲中心
Local 本地音乐
独唱、小合唱
大合唱
diff --git a/KTVAPI/Android/app/src/main/res/values/colors.xml b/KTVAPI/Android/app/src/main/res/values/colors.xml
index e9064a5..d6c5632 100644
--- a/KTVAPI/Android/app/src/main/res/values/colors.xml
+++ b/KTVAPI/Android/app/src/main/res/values/colors.xml
@@ -1,7 +1,9 @@
#FF000000
+ #FF333333
#FFFFFFFF
#AAA
#DDD
+ #FAFCFB
\ No newline at end of file
diff --git a/KTVAPI/Android/app/src/main/res/values/strings.xml b/KTVAPI/Android/app/src/main/res/values/strings.xml
index 4324cec..34bd80e 100644
--- a/KTVAPI/Android/app/src/main/res/values/strings.xml
+++ b/KTVAPI/Android/app/src/main/res/values/strings.xml
@@ -1,5 +1,5 @@
- KTVAPI-Demo
+ KtvApi-Demo
主唱
合唱
观众
@@ -16,7 +16,8 @@
关麦
请选择初始演唱身份!
请输入 channel name
- MCC 声网歌曲中心
+ Mcc 声网歌曲中心
+ MccEx 歌曲中心
Local 本地音乐
独唱、小合唱
大合唱
diff --git a/KTVAPI/Android/app/src/main/res/values/themes.xml b/KTVAPI/Android/app/src/main/res/values/themes.xml
index 7106bd0..fd89498 100644
--- a/KTVAPI/Android/app/src/main/res/values/themes.xml
+++ b/KTVAPI/Android/app/src/main/res/values/themes.xml
@@ -11,5 +11,6 @@
- @color/black
- false
+ - @android:color/transparent
\ No newline at end of file
diff --git a/KTVAPI/Android/gradle.properties b/KTVAPI/Android/gradle.properties
index ddc3612..ff14e9a 100644
--- a/KTVAPI/Android/gradle.properties
+++ b/KTVAPI/Android/gradle.properties
@@ -31,6 +31,12 @@ TOOLBOX_SERVER_HOST=https://service.agora.io/toolbox
AGORA_APP_ID=
AGORA_APP_CERTIFICATE=
-# Restful Api Config (Giant Chorus Only)
+# Cloud Player Config
RESTFUL_API_KEY=
-RESTFUL_API_SECRET=
\ No newline at end of file
+RESTFUL_API_SECRET=
+
+# API EX Config
+EX_APP_ID=
+EX_APP_Key=
+EX_APP_TOKEN=
+EX_USERID=
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/.gitignore b/KTVAPI/Android/lib_ktvapi_ex/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/build.gradle b/KTVAPI/Android/lib_ktvapi_ex/build.gradle
new file mode 100644
index 0000000..3ec919b
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/build.gradle
@@ -0,0 +1,114 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+apply plugin: 'maven-publish'
+//apply plugin: 'com.google.protobuf'
+
+android {
+ namespace 'io.agora.ktvex'
+ compileSdk 33
+
+ defaultConfig {
+ minSdk 21
+ targetSdk 33
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ debug{
+ minifyEnabled false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ buildFeatures {
+ viewBinding true
+ }
+ lintOptions {
+ checkReleaseBuilds false
+ abortOnError false
+ }
+// sourceSets {
+// main {
+// //实际测试指不指定无所谓,不影响 Java 文件生成
+// proto {
+// srcDir 'src/main'
+// }
+// }
+// }
+}
+
+//protobuf {
+// //配置 protoc 编译器
+// protoc {
+// artifact = 'com.google.protobuf:protoc:3.19.2'
+// }
+// //配置生成目录,编译后会在 build 的目录下生成对应的java文件
+// generateProtoTasks {
+// all().each { task ->
+// task.builtins {
+// remove java
+// }
+// task.builtins {
+// java {}
+// }
+// }
+// }
+//}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.7.0'
+ implementation 'androidx.appcompat:appcompat:1.4.1'
+ implementation 'com.google.android.material:material:1.5.0'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+
+ api "io.agora.rtc:agora-special-full:4.3.2.4"
+ api ("io.github.winskyan:Agora-MccExService:2.2.0.132-alpha.9") {
+ exclude group: 'io.agora.rtc', module: 'agora-special-full'
+ }
+
+ implementation 'com.google.protobuf:protobuf-java:3.19.3'
+ implementation 'com.google.protobuf:protobuf-java-util:3.19.3'
+}
+
+// Because the components are created only during the afterEvaluate phase, you must
+// configure your publications using the afterEvaluate() lifecycle method.
+afterEvaluate {
+ publishing {
+ publications {
+ // Creates a Maven publication called "release".
+ release(MavenPublication) {
+ // Applies the component for the release build variant.
+ from components.release
+
+ // You can then customize attributes of the publication as shown below.
+ groupId = 'io.github.agoraio-community'
+ artifactId = 'scenarioapi-ktv'
+ version = '1.0.0.2'
+ }
+ // Creates a Maven publication called “debug”.
+ debug(MavenPublication) {
+ // Applies the component for the debug build variant.
+ from components.debug
+
+ groupId = 'io.github.agoraio-community'
+ artifactId = 'scenarioapi-ktv'
+ version = '1.0.0.2'
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/consumer-rules.pro b/KTVAPI/Android/lib_ktvapi_ex/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/KTVAPI/Android/lib_ktvapi_ex/proguard-rules.pro b/KTVAPI/Android/lib_ktvapi_ex/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/AndroidManifest.xml b/KTVAPI/Android/lib_ktvapi_ex/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1340310
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/LrcTime.proto b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/LrcTime.proto
new file mode 100644
index 0000000..d468305
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/LrcTime.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+
+enum MsgType {
+ UNKNOWN_TYPE = 0;
+ LRC_TIME = 1001;
+}
+
+message LrcTime {
+ MsgType type = 1;
+ bool forward = 2;
+ int64 ts = 3;
+ string songId = 4;
+ int32 uid = 5;
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/APIReporter.kt b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/APIReporter.kt
new file mode 100644
index 0000000..5057a4a
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/APIReporter.kt
@@ -0,0 +1,141 @@
+package io.agora.ktvapiex
+
+import android.util.Log
+import io.agora.rtc2.Constants
+import io.agora.rtc2.RtcEngine
+import org.json.JSONObject
+import java.util.HashMap
+
+enum class APIType(val value: Int) {
+ KTV(1), // K歌
+ CALL(2), // 呼叫连麦
+ BEAUTY(3), // 美颜
+ VIDEO_LOADER(4), // 秒开秒切
+ PK(5), // 团战
+ VIRTUAL_SPACE(6), //
+ SCREEN_SPACE(7), // 屏幕共享
+ AUDIO_SCENARIO(8) // 音频
+}
+
+enum class ApiEventType(val value: Int) {
+ API(0),
+ COST(1),
+ CUSTOM(2)
+}
+
+object ApiEventKey {
+ const val TYPE = "type"
+ const val DESC = "desc"
+ const val API_VALUE = "apiValue"
+ const val TIMESTAMP = "ts"
+ const val EXT = "ext"
+}
+
+object ApiCostEvent {
+ const val CHANNEL_USAGE = "channelUsage" //频道使用耗时
+ const val FIRST_FRAME_ACTUAL = "firstFrameActual" //首帧实际耗时
+ const val FIRST_FRAME_PERCEIVED = "firstFramePerceived" //首帧感官耗时
+}
+
+class APIReporter(
+ private val type: APIType,
+ private val version: String,
+ private val rtcEngine: RtcEngine
+) {
+ private val tag = "APIReporter"
+ private val messageId = "agora:scenarioAPI"
+ private val durationEventStartMap = HashMap()
+ private val category = "${type.value}_Android_$version"
+
+ init {
+ configParameters()
+ }
+
+ // 上报普通场景化API
+ fun reportFuncEvent(name: String, value: Map, ext: Map) {
+ writeLog("reportFuncEvent: $name value: $value ext: $ext", Constants.LOG_LEVEL_INFO)
+ val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.API.value, ApiEventKey.DESC to name)
+ val labelMap = mapOf(ApiEventKey.API_VALUE to value, ApiEventKey.TIMESTAMP to getCurrentTs(), ApiEventKey.EXT to ext)
+ val event = convertToJSONString(eventMap) ?: ""
+ val label = convertToJSONString(labelMap) ?: ""
+ rtcEngine.sendCustomReportMessage(messageId, category, event, label, 0)
+ }
+
+ fun startDurationEvent(name: String) {
+ Log.d(tag, "startDurationEvent: $name")
+ durationEventStartMap[name] = getCurrentTs()
+ }
+
+ fun endDurationEvent(name: String, ext: Map) {
+ Log.d(tag, "endDurationEvent: $name")
+ val beginTs = durationEventStartMap[name] ?: return
+ durationEventStartMap.remove(name)
+ val ts = getCurrentTs()
+ val cost = (ts - beginTs).toInt()
+
+ innerReportCostEvent(ts, name, cost, ext)
+ }
+
+ // 上报耗时打点信息
+ fun reportCostEvent(name: String, cost: Int, ext: Map) {
+ durationEventStartMap.remove(name)
+ innerReportCostEvent(
+ ts = getCurrentTs(),
+ name = name,
+ cost = cost,
+ ext = ext
+ )
+ }
+
+ // 上报自定义信息
+ fun reportCustomEvent(name: String, ext: Map) {
+ Log.d(tag, "reportCustomEvent: $name ext: $ext")
+ val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.CUSTOM.value, ApiEventKey.DESC to name)
+ val labelMap = mapOf(ApiEventKey.TIMESTAMP to getCurrentTs(), ApiEventKey.EXT to ext)
+ val event = convertToJSONString(eventMap) ?: ""
+ val label = convertToJSONString(labelMap) ?: ""
+ rtcEngine.sendCustomReportMessage(messageId, category, event, label, 0)
+ }
+
+ fun writeLog(content: String, level: Int) {
+ Log.d(tag, content)
+ rtcEngine.writeLog(level, content)
+ }
+
+ fun cleanCache() {
+ durationEventStartMap.clear()
+ }
+
+ // ---------------------- private ----------------------
+
+ private fun configParameters() {
+ //rtcEngine.setParameters("{\"rtc.qos_for_test_purpose\": true}") //测试环境使用
+ // 数据上报
+ rtcEngine.setParameters("{\"rtc.direct_send_custom_event\": true}")
+ // 日志写入
+ rtcEngine.setParameters("{\"rtc.log_external_input\": true}")
+ }
+
+ private fun getCurrentTs(): Long {
+ return System.currentTimeMillis()
+ }
+
+ private fun innerReportCostEvent(ts: Long, name: String, cost: Int, ext: Map) {
+ Log.d(tag, "reportCostEvent: $name cost: $cost ms ext: $ext")
+ writeLog("reportCostEvent: $name cost: $cost ms", Constants.LOG_LEVEL_INFO)
+ val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.COST.value, ApiEventKey.DESC to name)
+ val labelMap = mapOf(ApiEventKey.TIMESTAMP to ts, ApiEventKey.EXT to ext)
+ val event = convertToJSONString(eventMap) ?: ""
+ val label = convertToJSONString(labelMap) ?: ""
+ rtcEngine.sendCustomReportMessage(messageId, category, event, label, cost)
+ }
+
+ private fun convertToJSONString(dictionary: Map): String? {
+ return try {
+ JSONObject(dictionary).toString()
+ } catch (e: Exception) {
+ writeLog("[$tag]convert to json fail: $e dictionary: $dictionary", Constants.LOG_LEVEL_WARNING)
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApi.kt b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApi.kt
new file mode 100644
index 0000000..429d9b3
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApi.kt
@@ -0,0 +1,602 @@
+package io.agora.ktvapiex
+
+import io.agora.mccex.IMusicContentCenterEx
+import io.agora.mccex.constants.MccExState
+import io.agora.mccex.constants.MccExStateReason
+import io.agora.mccex.model.LineScoreData
+import io.agora.mccex.model.RawScoreData
+import io.agora.mediaplayer.Constants
+import io.agora.mediaplayer.IMediaPlayer
+import io.agora.rtc2.IRtcEngineEventHandler
+import io.agora.rtc2.RtcEngine
+
+/**
+ * KTV场景类型
+ * @param Normal 普通独唱或多人合唱
+ * @param SingBattle 嗨歌抢唱
+ * @param SingRelay 抢麦接唱
+ */
+enum class KTVType(val value: Int) {
+ Normal(0),
+ SingBattle(1),
+ SingRelay(2)
+}
+
+/**
+ * KTV歌曲类型
+ * @param SONG_CODE mcc版权歌单songCode
+ * @param SONG_URL 本地歌曲地址url
+ */
+enum class KTVMusicType(val value: Int) {
+ SONG_CODE(0),
+ SONG_URL(1)
+}
+
+/**
+ * 在KTVApi中的身份
+ * @param SoloSinger 独唱者: 当前只有自己在唱歌
+ * @param CoSinger 伴唱: 加入合唱需要通过调用switchSingerRole将切换身份成合唱
+ * @param LeadSinger 主唱: 有合唱者加入后,需要通过调用switchSingerRole切换身份成主唱
+ * @param Audience 听众: 默认状态
+ */
+enum class KTVSingRole(val value: Int) {
+ SoloSinger(0),
+ CoSinger(1),
+ LeadSinger(2),
+ Audience(3)
+}
+
+/**
+ * loadMusic失败的原因
+ * @param NO_LYRIC_URL 没有歌词,不影响音乐正常播放
+ * @param MUSIC_PRELOAD_FAIL 音乐加载失败
+ * @param CANCELED 本次加载已终止
+ */
+enum class KTVLoadMusicFailReason(val value: Int) {
+ NO_LYRIC_URL(0),
+ MUSIC_PRELOAD_FAIL(1),
+ CANCELED(2),
+ GET_SIMPLE_INFO_FAIL(3)
+}
+
+/**
+ * switchSingerRole的失败的原因
+ * @param JOIN_CHANNEL_FAIL 加入channel2失败
+ * @param NO_PERMISSION switchSingerRole传入了错误的目标角色(不能从当前角色切换到目标角色)
+ */
+enum class SwitchRoleFailReason(val value: Int) {
+ JOIN_CHANNEL_FAIL(0),
+ NO_PERMISSION(1)
+}
+
+/**
+ * 加入合唱错误原因
+ * @param JOIN_CHANNEL_FAIL 加入合唱子频道失败
+ * @param MUSIC_OPEN_FAIL 歌曲open失败
+ */
+enum class KTVJoinChorusFailReason(val value: Int) {
+ JOIN_CHANNEL_FAIL(0),
+ MUSIC_OPEN_FAIL(1)
+}
+
+
+/**
+ * 加载音乐的模式
+ * @param LOAD_MUSIC_ONLY 只加载音乐(通常加入合唱前使用此模式)
+ * @param LOAD_LRC_ONLY 只加载歌词(通常歌曲开始播放时观众使用此模式)
+ * @param LOAD_MUSIC_AND_LRC 默认模式,加载歌词和音乐(通常歌曲开始播放时主唱使用此模式)
+ */
+enum class KTVLoadMusicMode(val value: Int) {
+ LOAD_NONE(-1),
+ LOAD_MUSIC_ONLY(0),
+ LOAD_LRC_ONLY(1),
+ LOAD_MUSIC_AND_LRC(2)
+}
+
+/**
+ * 加载音乐的状态
+ * @param COMPLETED 加载完成, 进度为100
+ * @param FAILED 加载失败
+ * @param INPROGRESS 加载中
+ */
+enum class MusicLoadStatus(val value: Int) {
+ COMPLETED(0),
+ FAILED(1),
+ INPROGRESS(2),
+}
+
+/**
+ * 音乐音轨模式
+ * @param YUAN_CHANG 原唱:主唱开启原唱后,自己听到原唱,听众听到原唱
+ * @param BAN_ZOU 伴奏:主唱开启伴奏后,自己听到伴奏,听众听到伴奏
+ * @param DAO_CHANG 导唱:主唱开启导唱后,自己听到原唱,听众听到伴奏
+ */
+enum class AudioTrackMode(val value: Int) {
+ YUAN_CHANG(0),
+ BAN_ZOU(1),
+ DAO_CHANG(2),
+}
+
+/**
+ * 大合唱中演唱者互相收听对方音频流的选路策略
+ * @param RANDOM 随机选取几条流
+ * @param BY_DELAY 根据延迟选择最低的几条流
+ * @param TOP_N 根据音强选流
+ * @param BY_DELAY_AND_TOP_N 同时开始延迟选路和音强选流
+ */
+enum class GiantChorusRouteSelectionType(val value: Int) {
+ RANDOM(0),
+ BY_DELAY(1),
+ TOP_N(2),
+ BY_DELAY_AND_TOP_N(3)
+}
+
+/**
+ * 大合唱中演唱者互相收听对方音频流的选路配置
+ * @param type 选路策略
+ * @param streamNum 最大选取的流个数(推荐6)
+ */
+data class GiantChorusRouteSelectionConfig constructor(
+ val type: GiantChorusRouteSelectionType,
+ val streamNum: Int
+)
+
+/**
+ * 歌词组件接口,您setLrcView传入的歌词组件需要继承此接口类,并实现以下几个方法
+ */
+interface ILrcView {
+
+ /**
+ * ktvApi内部更新音乐播放进度progress时会主动调用此方法将进度值progress传给你的歌词组件,50ms回调一次
+ * @param progress 歌曲播放的真实进度 20ms回调一次
+ */
+ fun onUpdateProgress(progress: Long?)
+
+ /**
+ * ktvApi内部更新音高pitch时会主动调用此方法将pitch值传给你的歌词组件
+ * @param songCode 歌曲编码,和loadMusic传入的songCode一致
+ * @param pitch 音高
+ * @param progressInMs 歌曲播放的进度
+ */
+ fun onUpdatePitch(songCode: Long, pitch: Double, progressInMs: Int)
+
+ fun onLineScore(songCode: Long, value: LineScoreData)
+
+ /**
+ * ktvApi获取到歌词地址时会主动调用此方法将歌词地址url传给你的歌词组件,您需要在这个回调内完成歌词的下载
+ */
+ fun onDownloadLrcData(lyricPath: String?, pitchPath: String?)
+}
+
+/**
+ * 音乐加载状态接口
+ */
+interface IMusicLoadStateListener {
+ /**
+ * 音乐加载成功
+ * @param songCode 歌曲编码,和loadMusic传入的songCode一致
+ * @param lyricUrl 歌词地址
+ */
+ fun onMusicLoadSuccess(songCode: Long, lyricUrl: String)
+
+ /**
+ * 音乐加载失败
+ * @param songCode 加载失败的歌曲编码
+ * @param reason 歌曲加载失败的原因
+ */
+ fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason)
+
+ /**
+ * 音乐加载进度
+ * @param songCode 歌曲编码
+ * @param percent 歌曲加载进度
+ * @param status 歌曲加载的状态
+ * @param msg 相关信息
+ * @param lyricUrl 歌词地址
+ */
+ fun onMusicLoadProgress(songCode: Long, percent: Int, status: MusicLoadStatus, msg: String?, lyricUrl: String?)
+}
+
+/**
+ * 切换演唱角色状态接口
+ */
+interface ISwitchRoleStateListener {
+ /**
+ * 切换演唱角色成功
+ */
+ fun onSwitchRoleSuccess()
+
+ /**
+ * 切换演唱角色失败
+ * @param reason 切换演唱角色失败的原因
+ */
+ fun onSwitchRoleFail(reason: SwitchRoleFailReason)
+}
+
+interface OnJoinChorusStateListener {
+ /**
+ * 切换演唱角色成功
+ */
+ fun onJoinChorusSuccess()
+
+ /**
+ * 切换演唱角色失败
+ * @param reason 切换演唱角色失败的原因
+ */
+ fun onJoinChorusFail(reason: KTVJoinChorusFailReason)
+}
+
+/**
+ * KTVApi事件回调
+ */
+abstract class IKTVApiEventHandler {
+ /**
+ * 播放器状态变化
+ * @param state MediaPlayer 播放状态
+ * @param reason MediaPlayer Error 信息
+ * @param isLocal 本地还是主唱端的 Player 信息
+ */
+ open fun onMusicPlayerStateChanged(
+ state: Constants.MediaPlayerState, reason: Constants.MediaPlayerReason, isLocal: Boolean
+ ) {
+ }
+
+ /**
+ * ktvApi内部角色切换
+ * @param oldRole 老角色
+ * @param newRole 新角色
+ */
+ open fun onSingerRoleChanged(oldRole: KTVSingRole, newRole: KTVSingRole) {}
+
+ /**
+ * rtm或合唱频道token将要过期回调,需要renew这个token
+ */
+ open fun onTokenPrivilegeWillExpire() {}
+
+ /**
+ * 合唱频道人声音量提示
+ * @param speakers 不同用户音量信息
+ * @param totalVolume 总音量
+ */
+ open fun onChorusChannelAudioVolumeIndication(
+ speakers: Array?,
+ totalVolume: Int
+ ) {
+ }
+
+ /**
+ * 播放进度回调
+ * @param position_ms 音乐播放的进度
+ */
+ open fun onMusicPlayerPositionChanged(position_ms: Long, timestamp_ms: Long) {}
+}
+
+open class KTVConfig {}
+
+/**
+ * 初始化KTVApi的配置
+ * @param appId 用来初始化 Mcc Engine
+ * @param mMusicCenter IMusicContentCenterEx 对象
+ * @param engine RTC engine 对象
+ * @param channelName 频道号,子频道名以基于主频道名 + "_ex" 固定规则生成频道号
+ * @param localUid 创建 Mcc engine 和 加入子频道需要用到
+ * @param chorusChannelName 子频道名 加入子频道需要用到
+ * @param chorusChannelToken 子频道token 加入子频道需要用到
+ * @param maxCacheSize 最大缓存歌曲数
+ * @param type KTV场景
+ * @param musicType 音乐类型
+ */
+data class KTVApiConfig constructor(
+ val appId: String,
+ val mMusicCenter: IMusicContentCenterEx,
+ val engine: RtcEngine,
+ val channelName: String,
+ val localUid: Int,
+ val chorusChannelName: String,
+ var chorusChannelToken: String,
+ val maxCacheSize: Int = 10,
+ val type: KTVType = KTVType.Normal,
+ val musicType: KTVMusicType = KTVMusicType.SONG_CODE
+) : KTVConfig() {
+ override fun toString(): String {
+ return "channelName:$channelName, localUid:$localUid, chorusChannelName:$chorusChannelName, type:$type, musicType:$musicType"
+ }
+}
+
+/**
+ * 初始化KTVGiantChorusApi的配置
+ * @param appId 用来初始化 Mcc Engine
+ * @param mMusicCenter IMusicContentCenterEx 对象
+ * @param engine RTC engine 对象
+ * @param localUid 创建 Mcc engine 和 加入子频道需要用到
+ * @param audienceChannelName 观众频道名 加入听众频道需要用到
+ * @param audienceChannelToken 观众频道token 加入听众频道需要用到
+ * @param chorusChannelName 演唱频道名 加入演唱频道需要用到
+ * @param chorusChannelToken 演唱频道token 加入演唱频道需要用到
+ * @param musicStreamUid 音乐Uid 主唱推入频道
+ * @param musicStreamToken 音乐流token
+ * @param maxCacheSize 最大缓存歌曲数
+ * @param musicType 音乐类型
+ */
+data class KTVGiantChorusApiConfig constructor(
+ val appId: String,
+ val mMusicCenter: IMusicContentCenterEx,
+ val engine: RtcEngine,
+ val localUid: Int,
+ val audienceChannelName: String,
+ val audienceChannelToken: String,
+ val chorusChannelName: String,
+ val chorusChannelToken: String,
+ val musicStreamUid: Int,
+ val musicStreamToken: String,
+ val maxCacheSize: Int = 10,
+ val musicType: KTVMusicType = KTVMusicType.SONG_CODE
+) : KTVConfig() {
+ override fun toString(): String {
+ return "localUid=$localUid, audienceChannelName='$audienceChannelName', chorusChannelName='$chorusChannelName', musicStreamUid=$musicStreamUid, musicType=$musicType"
+ }
+}
+
+/**
+ * 加载歌曲的配置,不允许在一首歌没有load完成前(成功/失败均算完成)进行下一首歌的加载
+ * @param songIdentifier 歌曲 id,通常由业务方给每首歌设置一个不同的SongId用于区分
+ * @param mainSingerUid 主唱的 Uid,如果是伴唱,伴唱需要根据这个信息 mute 主频道主唱的声音
+ * @param mode 歌曲加载的模式,默认为音乐和歌词均加载
+ * @param needPrelude 播放切片歌曲情况下,是否播放
+ * @param needPitch 是否需要音调
+ */
+data class KTVLoadMusicConfiguration constructor(
+ val songIdentifier: String,
+ val mainSingerUid: Int,
+ val mode: KTVLoadMusicMode = KTVLoadMusicMode.LOAD_MUSIC_AND_LRC,
+ val needPrelude: Boolean = false,
+ val needPitch: Boolean = false
+) {
+ override fun toString(): String {
+ return "songIdentifier:$songIdentifier, mainSingerUid:$mainSingerUid, mode:$mode, needPrelude:$needPrelude, " +
+ "needPitch:$needPitch"
+ }
+}
+
+/**
+ * 创建普通合唱KTVApi实例
+ */
+fun createKTVApi(): KTVApi = KTVApiImpl()
+
+/**
+ * 创建大合唱KTVApi实例
+ */
+//fun createKTVGiantChorusApi(config: KTVGiantChorusApiConfig): KTVApi = KTVGiantChorusApiImpl(config)
+
+/**
+ * KTVApi 接口
+ */
+interface KTVApi {
+
+ companion object {
+ // 听到远端的音量
+ var remoteVolume: Int = 30
+
+ // 本地mpk播放音量
+ var mpkPlayoutVolume: Int = 50
+
+ // mpk发布音量
+ var mpkPublishVolume: Int = 50
+
+ // 是否使用音频自采集
+ var useCustomAudioSource = false
+
+ // 调试使用,会输出更多的日志
+ var debugMode = false
+
+ // 内部测试使用,无需关注
+ var mccDomain = ""
+
+ // 大合唱的选路策略
+ var routeSelectionConfig = GiantChorusRouteSelectionConfig(GiantChorusRouteSelectionType.BY_DELAY, 6)
+ }
+
+ /**
+ * 初始化内部变量/缓存数据,并注册相应的监听,必须在其他KTVApi调用前调用initialize初始化KTVApi
+ * @param ktvConfig 初始化KTVApi的配置
+ */
+ fun initialize(ktvConfig: KTVConfig)
+
+ /**
+ * 更新ktvapi内部使用的streamId,每次加入频道需要更新内部streamId
+ */
+ fun renewInnerDataStreamId()
+
+ /**
+ * 订阅KTVApi事件, 支持多注册
+ * @param ktvApiEventHandler KTVApi事件接口实例
+ */
+ fun addEventHandler(ktvApiEventHandler: IKTVApiEventHandler)
+
+ /**
+ * 取消订阅KTVApi事件
+ * @param ktvApiEventHandler KTVApi事件接口实例
+ */
+ fun removeEventHandler(ktvApiEventHandler: IKTVApiEventHandler)
+
+ /**
+ * 清空内部变量/缓存,取消在initWithRtcEngine时的监听,以及取消网络请求等
+ */
+ fun release()
+
+ /**
+ * 收到 IKTVApiEventHandler.onTokenPrivilegeWillExpire 回调时需要主动调用方法更新Token
+ * @param rtmToken musicContentCenter模块需要的rtm token
+ * @param chorusChannelRtcToken 合唱需要的频道rtc token
+ */
+ fun renewToken(
+ rtmToken: String,
+ chorusChannelRtcToken: String
+ )
+
+ /**
+ * 异步加载歌曲,同时只能为一首歌loadSong,loadSong结果会通过回调通知业务层
+ * @param songCode 歌曲唯一编码
+ * @param config 加载歌曲配置
+ * @param musicLoadStateListener 加载歌曲结果回调
+ *
+ * 推荐调用:
+ * 歌曲开始时:
+ * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, songCode, mainSingerUid)) switchSingerRole(SoloSinger)
+ * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, songCode, mainSingerUid))
+ * 加入合唱时:
+ * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, songCode, mainSingerUid))
+ * loadMusic成功后switchSingerRole(CoSinger)
+ */
+ fun loadMusic(
+ songCode: Long,
+ config: KTVLoadMusicConfiguration,
+ musicLoadStateListener: IMusicLoadStateListener
+ )
+
+ /**
+ * 取消加载歌曲,会打断加载歌曲的进程并移除歌曲缓存
+ * @param songCode 歌曲唯一编码
+ */
+ fun removeMusic(songCode: Long)
+
+ /**
+ * 加载歌曲,同时只能为一首歌loadSong,同步调用, 一般使用此loadSong是歌曲已经preload成功(url为本地文件地址)
+ * @param url 歌曲地址
+ * @param config 加载歌曲配置
+ *
+ * 推荐调用:
+ * 歌曲开始时:
+ * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger)
+ * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, url, mainSingerUid))
+ * 加入合唱时:
+ * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, url, mainSingerUid))
+ * loadMusic成功后switchSingerRole(CoSinger)
+ */
+ fun loadMusic(
+ url: String,
+ config: KTVLoadMusicConfiguration
+ ) {
+ }
+
+ /**
+ * 加载歌曲,同时只能为一首歌loadSong,同步调用, 一般使用此loadSong是歌曲已经preload成功(url为本地文件地址)
+ * @param config 加载歌曲配置,默认播放url1
+ * @param url1 歌曲地址1
+ * @param url2 歌曲地址2
+ *
+ *
+ * 推荐调用:
+ * 歌曲开始时:
+ * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger)
+ * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, url, mainSingerUid))
+ * 加入合唱时:
+ * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, url, mainSingerUid))
+ * loadMusic成功后switchSingerRole(CoSinger)
+ */
+ fun load2Music(
+ url1: String,
+ url2: String,
+ config: KTVLoadMusicConfiguration
+ ) {
+ }
+
+ /**
+ * 多文件切换播放资源
+ * @param url 需要切换的播放资源,需要为 load2Music 中 参数 url1,url2 中的一个
+ * @param syncPts 是否同步切换前后的起始播放位置: true 同步,false 不同步,从 0 开始
+ */
+ fun switchPlaySrc(url: String, syncPts: Boolean) {}
+
+ fun startScore(
+ songCode: Long,
+ onStartScoreCallback: (songCode: Long, status: MccExState, msg: MccExStateReason) -> Unit
+ )
+
+ /**
+ * 异步切换演唱身份,结果会通过回调通知业务层
+ * @param newRole 新演唱身份
+ * @param switchRoleStateListener 切换演唱身份结果
+ *
+ * 允许的调用路径:
+ * 1、Audience -》SoloSinger 自己点的歌播放时
+ * 2、Audience -》LeadSinger 自己点的歌播放时, 且歌曲开始时就有合唱者加入
+ * 3、SoloSinger -》Audience 独唱结束时
+ * 4、Audience -》CoSinger 加入合唱时
+ * 5、CoSinger -》Audience 退出合唱时
+ * 6、SoloSinger -》LeadSinger 当前第一个合唱者加入合唱时,主唱由独唱切换成领唱
+ * 7、LeadSinger -》SoloSinger 最后一个合唱者退出合唱时,主唱由领唱切换成独唱
+ * 8、LeadSinger -》Audience 以领唱的身份结束歌曲时
+ */
+ fun switchSingerRole(
+ newRole: KTVSingRole,
+ switchRoleStateListener: ISwitchRoleStateListener?
+ )
+
+ /**
+ * 播放歌曲
+ * @param songCode 歌曲唯一编码
+ * @param startPos 开始播放的位置
+ */
+ fun startSing(songCode: Long, startPos: Long)
+
+ /**
+ * 播放歌曲
+ * @param url 歌曲地址
+ * @param startPos 开始播放的位置
+ */
+ fun startSing(url: String, startPos: Long) {}
+
+ /**
+ * 恢复播放
+ */
+ fun resumeSing()
+
+ /**
+ * 暂停播放
+ */
+ fun pauseSing()
+
+ /**
+ * 调整进度
+ */
+ fun seekSing(time: Long)
+
+ /**
+ * 设置歌词组件,在任意时机设置都可以生效
+ * @param view 传入的歌词组件view, 需要继承ILrcView并实现ILrcView的三个接口
+ */
+ fun setLrcView(view: ILrcView)
+
+ /**
+ * 开关麦
+ * @param mute true 关麦 false 开麦
+ */
+ fun muteMic(mute: Boolean)
+
+ /**
+ * 设置当前音频播放delay, 适用于音频自采集的情况
+ * @param audioPlayoutDelay 音频帧处理和播放的时间差
+ */
+ fun setAudioPlayoutDelay(audioPlayoutDelay: Int)
+
+ /**
+ * 获取mpk实例
+ */
+ fun getMediaPlayer(): IMediaPlayer
+
+ /**
+ * 切换音轨, 原唱/伴奏/导唱
+ */
+ fun switchAudioTrack(mode: AudioTrackMode)
+
+ /**
+ * 开启关闭专业模式,默认关
+ */
+ fun enableProfessionalStreamerMode(enable: Boolean)
+
+ /**
+ * 开启 Multipathing, 默认开
+ */
+ fun enableMulitpathing(enable: Boolean)
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApiImpl.kt b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApiImpl.kt
new file mode 100644
index 0000000..a98e5ed
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVApiImpl.kt
@@ -0,0 +1,1602 @@
+package io.agora.ktvapiex
+
+import android.os.Handler
+import android.os.Looper
+import io.agora.mccex.IMusicContentCenterEx
+import io.agora.mccex.IMusicContentCenterExEventHandler
+import io.agora.mccex.IMusicContentCenterExScoreEventHandler
+import io.agora.mccex.IMusicPlayer
+import io.agora.mccex.constants.LyricType
+import io.agora.mccex.constants.MccExState
+import io.agora.mccex.constants.MccExStateReason
+import io.agora.mccex.model.LineScoreData
+import io.agora.mccex.model.RawScoreData
+import io.agora.mediaplayer.Constants
+import io.agora.mediaplayer.Constants.MediaPlayerState
+import io.agora.mediaplayer.IMediaPlayer
+import io.agora.mediaplayer.IMediaPlayerObserver
+import io.agora.mediaplayer.data.CacheStatistics
+import io.agora.mediaplayer.data.PlayerPlaybackStats
+import io.agora.mediaplayer.data.PlayerUpdatedInfo
+import io.agora.mediaplayer.data.SrcInfo
+import io.agora.rtc2.*
+import io.agora.rtc2.Constants.*
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.concurrent.*
+
+class KTVApiImpl : KTVApi, IMediaPlayerObserver, IMusicContentCenterExEventHandler,
+ IMusicContentCenterExScoreEventHandler,
+ IRtcEngineEventHandler() {
+
+ companion object {
+ private val scheduledThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(5)
+ const val tag = "KTV_API_LOG"
+ const val version = "5.0.0"
+ const val lyricSyncVersion = 2
+ }
+
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
+ private lateinit var mRtcEngine: RtcEngineEx
+ private lateinit var mMusicCenter: IMusicContentCenterEx
+ private lateinit var mPlayer: IMusicPlayer
+
+ private lateinit var ktvApiConfig: KTVApiConfig
+ private lateinit var apiReporter: APIReporter
+
+ private var innerDataStreamId: Int = 0
+ private var subChorusConnection: RtcConnection? = null
+
+ private var mainSingerUid: Int = 0
+ private var songCode: Long = 0
+ private var songIdentifier: String = ""
+
+ private val lyricCallbackMap =
+ mutableMapOf Unit>() // (requestId, callback)
+ private val pitchCallbackMap =
+ mutableMapOf Unit>() // (requestId, callback)
+ private val lyricSongCodeMap = mutableMapOf() // (requestId, songCode)
+ private val startScoreMap =
+ mutableMapOf Unit>() // (songNo, callback)
+ private val loadMusicCallbackMap =
+ mutableMapOf Unit>() // (songNo, callback)
+
+ private var lrcView: ILrcView? = null
+
+ private var localPlayerPosition: Long = 0
+ private var localPlayerSystemTime: Long = 0
+
+ //歌词实时刷新
+ private var mReceivedPlayPosition: Long = 0 //播放器播放position,ms
+ private var mLastReceivedPlayPosTime: Long? = null
+
+ // event
+ private var ktvApiEventHandlerList = mutableListOf()
+ private var mainSingerHasJoinChannelEx: Boolean = false
+
+ // 合唱校准
+ private var audioPlayoutDelay = 0
+
+ // 音高
+ private var pitch = 0.0
+ private var progressInMs = 0
+
+ // 是否在麦上
+ private var isOnMicOpen = false
+ private var isRelease = false
+
+ // mpk状态
+ private var mediaPlayerState: MediaPlayerState = MediaPlayerState.PLAYER_STATE_IDLE
+
+ // multipath
+ private var enableMultipathing = true
+
+ private var professionalModeOpen = false
+ private var audioRouting = 0
+ private var isPublishAudio = false // 通过是否发音频流判断
+
+ // 抢唱模式下是否需要prelude
+ private var needPrelude = false
+
+ // 歌词信息是否来源于 dataStream
+ private var recvFromDataStream = false
+
+ // 开始播放歌词
+ private var mStopDisplayLrc = true
+ private var displayLrcFuture: ScheduledFuture<*>? = null
+ private val displayLrcTask = object : Runnable {
+ override fun run() {
+ if (!mStopDisplayLrc) {
+ if (singerRole == KTVSingRole.Audience && !recvFromDataStream) return // audioMetaData方案观众return
+ val lastReceivedTime = mLastReceivedPlayPosTime ?: return
+ val curTime = System.currentTimeMillis()
+ val offset = curTime - lastReceivedTime
+ if (offset <= 1000) {
+ val curTs = mReceivedPlayPosition + offset
+ if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger) {
+ val lrcTime = LrcTimeOuterClass.LrcTime.newBuilder()
+ .setTypeValue(LrcTimeOuterClass.MsgType.LRC_TIME.number)
+ .setForward(true)
+ .setSongId(songIdentifier)
+ .setTs(curTs)
+ .setUid(ktvApiConfig.localUid)
+ .build()
+
+ mRtcEngine.sendAudioMetadata(lrcTime.toByteArray())
+ }
+ runOnMainThread {
+ lrcView?.onUpdatePitch(songCode, pitch, progressInMs)
+ // (fix ENT-489)Make lyrics delay for 200ms
+ // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`,
+ // such as AEC/Player/Device buffer.
+ // We choose the estimated 200ms.
+ lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side
+ }
+ }
+ }
+ }
+ }
+
+ // 音高同步
+ private var mStopSyncPitch = true
+ private var mSyncPitchFuture: ScheduledFuture<*>? = null
+ private val mSyncPitchTask = Runnable {
+ if (!mStopSyncPitch) {
+ if (ktvApiConfig.type == KTVType.SingRelay &&
+ (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger || singerRole == KTVSingRole.CoSinger) &&
+ isOnMicOpen
+ ) {
+ sendSyncPitch(pitch, progressInMs)
+ } else if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING &&
+ (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger)
+ ) {
+ sendSyncPitch(pitch, progressInMs)
+ }
+ }
+ }
+
+ override fun initialize(ktvConfig: KTVConfig) {
+ val config = ktvConfig as KTVApiConfig
+ this.mRtcEngine = config.engine as RtcEngineEx
+ this.apiReporter = APIReporter(APIType.KTV, version, mRtcEngine)
+ this.ktvApiConfig = config
+ apiReporter.reportFuncEvent("initialize", mapOf("config" to ktvApiConfig.toString()), mapOf())
+
+ mMusicCenter = config.mMusicCenter
+ mMusicCenter.registerEventHandler(this)
+ mMusicCenter.registerScoreEventHandler(this)
+
+ // ------------------ 初始化音乐播放器实例 ------------------
+ mPlayer = mMusicCenter.createMusicPlayer()!!
+ mPlayer.adjustPublishSignalVolume(KTVApi.mpkPublishVolume)
+ mPlayer.adjustPlayoutVolume(KTVApi.mpkPlayoutVolume)
+
+ // 注册回调
+ mRtcEngine.addHandler(this)
+ mPlayer.registerPlayerObserver(this)
+
+ renewInnerDataStreamId()
+ setKTVParameters()
+ startDisplayLrc()
+ startSyncPitch()
+ isRelease = false
+
+ if (ktvApiConfig.type == KTVType.SingRelay) {
+ KTVApi.remoteVolume = 100
+ }
+ mPlayer.setPlayerOption("play_pos_change_callback", 100)
+ }
+
+ // 日志输出
+ private fun ktvApiLog(msg: String) {
+ if (isRelease) return
+ apiReporter.writeLog("[$tag][${ktvApiConfig.type}] $msg", LOG_LEVEL_INFO)
+ }
+
+ // 日志输出
+ private fun ktvApiLogError(msg: String) {
+ if (isRelease) return
+ apiReporter.writeLog("[$tag][${ktvApiConfig.type}] $msg", LOG_LEVEL_ERROR)
+ }
+
+ override fun renewInnerDataStreamId() {
+ apiReporter.reportFuncEvent("renewInnerDataStreamId", mapOf(), mapOf())
+
+ val innerCfg = DataStreamConfig()
+ innerCfg.syncWithAudio = true
+ innerCfg.ordered = false
+ this.innerDataStreamId = mRtcEngine.createDataStream(innerCfg)
+ }
+
+ private fun setKTVParameters() {
+ mRtcEngine.setParameters("{\"rtc.enable_nasa2\": true}")
+ mRtcEngine.setParameters("{\"rtc.ntp_delay_drop_threshold\":1000}")
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": true}")
+ mRtcEngine.setParameters("{\"rtc.net.maxS2LDelay\": 800}")
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}")
+
+ mRtcEngine.setParameters("{\"rtc.net.maxS2LDelayBroadcast\":400}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\":600}")
+ mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 8}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ mRtcEngine.setParameters("{\"che.audio.uplink_apm_async_process\": true}")
+
+ // 标准音质
+ mRtcEngine.setParameters("{\"che.audio.aec.split_srate_for_48k\": 16000}")
+
+ // ENT-901
+ mRtcEngine.setParameters("{\"che.audio.ans.noise_gate\": 20}")
+
+ // Android Only
+ mRtcEngine.setParameters("{\"che.audio.enable_estimated_device_delay\":false}")
+
+ // ENT-1036
+ if (ktvApiConfig.type == KTVType.SingRelay) {
+ mRtcEngine.setParameters("{\"che.audio.aiaec.working_mode\":1}")
+ }
+
+ // 歌词强同步需要在audio4环境
+ mRtcEngine.setParameters("{\"rtc.use_audio4\": true}")
+
+ // mutipath
+ enableMultipathing = true
+ //mRtcEngine.setParameters("{\"rtc.enableMultipath\": true}")
+ mRtcEngine.setParameters("{\"rtc.enable_tds_request_on_join\": true}")
+ //mRtcEngine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}")
+ //mRtcEngine.setParameters("{\"rtc.path_scheduling_strategy\": 0}")
+ }
+
+ private fun resetParameters() {
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}") // 兼容之前的profile = 3设置
+ mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 3}") // 正常3路下行流混流
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\": false}") // 关闭 接收端快速对齐模式
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": false}") // 观众关闭 多端同步
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": false}") //主播关闭多端同步
+ }
+
+ override fun addEventHandler(ktvApiEventHandler: IKTVApiEventHandler) {
+ apiReporter.reportFuncEvent("addEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf())
+ ktvApiEventHandlerList.add(ktvApiEventHandler)
+ }
+
+ override fun removeEventHandler(ktvApiEventHandler: IKTVApiEventHandler) {
+ apiReporter.reportFuncEvent("removeEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf())
+ ktvApiEventHandlerList.remove(ktvApiEventHandler)
+ }
+
+ override fun release() {
+ apiReporter.reportFuncEvent("release", mapOf(), mapOf())
+ if (isRelease) return
+ isRelease = true
+ singerRole = KTVSingRole.Audience
+
+ resetParameters()
+ stopSyncPitch()
+ stopDisplayLrc()
+ this.mLastReceivedPlayPosTime = null
+ this.mReceivedPlayPosition = 0
+ this.innerDataStreamId = 0
+
+ lyricCallbackMap.clear()
+ pitchCallbackMap.clear()
+ loadMusicCallbackMap.clear()
+ lyricCallbackMap.clear()
+ startScoreMap.clear()
+ lrcView = null
+
+ mRtcEngine.removeHandler(this)
+ mPlayer.unRegisterPlayerObserver(this)
+ mMusicCenter.unregisterEventHandler()
+
+ mPlayer.stop()
+ mPlayer.destroy()
+ IMusicContentCenterEx.destroy()
+
+ mainSingerHasJoinChannelEx = false
+ professionalModeOpen = false
+ audioRouting = 0
+ isPublishAudio = false
+ }
+
+ override fun enableProfessionalStreamerMode(enable: Boolean) {
+ apiReporter.reportFuncEvent("enableProfessionalStreamerMode", mapOf("enable" to enable), mapOf())
+ this.professionalModeOpen = enable
+ processAudioProfessionalProfile()
+ }
+
+ // 专业模式
+ private fun processAudioProfessionalProfile() {
+ ktvApiLog("processAudioProfessionalProfile: audioRouting: $audioRouting, professionalModeOpen: $professionalModeOpen, isPublishAudio:$isPublishAudio")
+ if (!isPublishAudio) return // 必须为麦上者
+ if (professionalModeOpen) {
+ // 专业
+ if (audioRouting == AUDIO_ROUTE_HEADSET || audioRouting == AUDIO_ROUTE_HEADSETNOMIC || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP || audioRouting == AUDIO_ROUTE_USBDEVICE || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_A2DP) {
+ // 耳机 关闭3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo
+ } else {
+ // 非耳机 开启3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo
+ }
+ } else {
+ // 非专业 开启3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_STANDARD_STEREO) // AgoraAudioProfileMusicStandardStereo
+ }
+ }
+
+ override fun enableMulitpathing(enable: Boolean) {
+ apiReporter.reportFuncEvent("enableMulitpathing", mapOf("enable" to enable), mapOf())
+ this.enableMultipathing = enable
+
+ if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger) {
+ subChorusConnection?.let {
+ mRtcEngine.setParametersEx(
+ "{\"rtc.enableMultipath\": $enable, \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}",
+ it
+ )
+ }
+ }
+ }
+
+ override fun switchAudioTrack(mode: AudioTrackMode) {
+ apiReporter.reportFuncEvent("switchAudioTrack", mapOf("mode" to mode), mapOf())
+ when (singerRole) {
+ KTVSingRole.LeadSinger, KTVSingRole.SoloSinger -> {
+ when (mode) {
+ AudioTrackMode.YUAN_CHANG -> mPlayer.selectMultiAudioTrack(0, 0)
+ AudioTrackMode.BAN_ZOU -> mPlayer.selectMultiAudioTrack(1, 1)
+ AudioTrackMode.DAO_CHANG -> mPlayer.selectMultiAudioTrack(0, 1)
+ }
+ }
+
+ KTVSingRole.CoSinger -> {
+ when (mode) {
+ AudioTrackMode.YUAN_CHANG -> mPlayer.selectAudioTrack(0)
+ AudioTrackMode.BAN_ZOU -> mPlayer.selectAudioTrack(1)
+ AudioTrackMode.DAO_CHANG -> ktvApiLogError("CoSinger can not switch to DAO_CHANG")
+ }
+ }
+
+ KTVSingRole.Audience -> ktvApiLogError("CoSinger can not switch audio track")
+ }
+ }
+
+ override fun renewToken(rtmToken: String, chorusChannelRtcToken: String) {
+ apiReporter.reportFuncEvent("renewToken", mapOf(), mapOf())
+ // 更新RtmToken
+ mMusicCenter.renewToken(rtmToken)
+ // 更新合唱频道RtcToken
+ if (subChorusConnection != null) {
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.token = chorusChannelRtcToken
+ mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption, subChorusConnection)
+ ktvApiConfig.chorusChannelToken = chorusChannelRtcToken
+ }
+ }
+
+ // 1、Audience -》SoloSinger
+ // 2、Audience -》LeadSinger
+ // 3、SoloSinger -》Audience
+ // 4、Audience -》CoSinger
+ // 5、CoSinger -》Audience
+ // 6、SoloSinger -》LeadSinger
+ // 7、LeadSinger -》SoloSinger
+ // 8、LeadSinger -》Audience
+ // 9、CoSinger -》LeadSinger
+ var singerRole: KTVSingRole = KTVSingRole.Audience
+ override fun switchSingerRole(
+ newRole: KTVSingRole,
+ switchRoleStateListener: ISwitchRoleStateListener?
+ ) {
+ apiReporter.reportFuncEvent("switchSingerRole", mapOf("newRole" to newRole), mapOf())
+ val oldRole = singerRole
+
+ // 调整开关麦状态
+ if (ktvApiConfig.type != KTVType.SingRelay) {
+ if ((oldRole == KTVSingRole.LeadSinger || oldRole == KTVSingRole.SoloSinger) && (newRole == KTVSingRole.CoSinger || newRole == KTVSingRole.Audience) && !isOnMicOpen) {
+ mRtcEngine.muteLocalAudioStream(true)
+ mRtcEngine.adjustRecordingSignalVolume(100)
+ } else if ((oldRole == KTVSingRole.Audience || oldRole == KTVSingRole.CoSinger) && (newRole == KTVSingRole.LeadSinger || newRole == KTVSingRole.SoloSinger) && !isOnMicOpen) {
+ mRtcEngine.adjustRecordingSignalVolume(0)
+ mRtcEngine.muteLocalAudioStream(false)
+ }
+ }
+
+ if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.SoloSinger) {
+ // 1、Audience -》SoloSinger
+ this.singerRole = newRole
+ becomeSoloSinger()
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.LeadSinger) {
+ // 2、Audience -》LeadSinger
+ becomeSoloSinger()
+ joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener {
+ override fun onJoinChorusSuccess() {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusSuccess")
+ singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ }
+ }
+
+ override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusFail reason:$reason")
+ leaveChorus(newRole)
+ switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL)
+ }
+ }
+ })
+ } else if (this.singerRole == KTVSingRole.SoloSinger && newRole == KTVSingRole.Audience) {
+ // 3、SoloSinger -》Audience
+
+ stopSing()
+ this.singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+
+ } else if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.CoSinger) {
+ // 4、Audience -》CoSinger
+ joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener {
+ override fun onJoinChorusSuccess() {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusSuccess")
+ singerRole = newRole
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ }
+ }
+
+ override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusFail reason:$reason")
+ leaveChorus(newRole)
+ switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL)
+ }
+ }
+ })
+
+ } else if (this.singerRole == KTVSingRole.CoSinger && newRole == KTVSingRole.Audience) {
+ // 5、CoSinger -》Audience
+ leaveChorus(singerRole)
+
+ this.singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+
+ } else if (this.singerRole == KTVSingRole.SoloSinger && newRole == KTVSingRole.LeadSinger) {
+ // 6、SoloSinger -》LeadSinger
+
+ joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener {
+ override fun onJoinChorusSuccess() {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusSuccess")
+ singerRole = newRole
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ }
+ }
+
+ override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) {
+ runOnMainThread {
+ ktvApiLog("onJoinChorusFail reason:$reason")
+ leaveChorus(newRole)
+ switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL)
+ }
+ }
+ })
+ } else if (this.singerRole == KTVSingRole.LeadSinger && newRole == KTVSingRole.SoloSinger) {
+ // 7、LeadSinger -》SoloSinger
+ leaveChorus(singerRole)
+
+ this.singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else if (this.singerRole == KTVSingRole.LeadSinger && newRole == KTVSingRole.Audience) {
+ // 8、LeadSinger -》Audience
+ leaveChorus(singerRole)
+ stopSing()
+
+ this.singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else if (this.singerRole == KTVSingRole.CoSinger && newRole == KTVSingRole.LeadSinger) {
+ // 9、CoSinger -》LeadSinger
+ this.singerRole = newRole
+ syncNewLeadSinger(ktvApiConfig.localUid)
+ mRtcEngine.muteRemoteAudioStream(mainSingerUid, false)
+ mainSingerUid = ktvApiConfig.localUid
+
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}")
+
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio = true
+ channelMediaOption.publishMediaPlayerId = mPlayer.mediaPlayerId
+ channelMediaOption.publishMediaPlayerAudioTrack = true
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+
+ val channelMediaOption1 = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio = false
+ channelMediaOption.autoSubscribeVideo = false
+ channelMediaOption.publishMicrophoneTrack = true
+ channelMediaOption.enableAudioRecordingOrPlayout = false
+ channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER
+ mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption1, subChorusConnection)
+ } else {
+ switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.NO_PERMISSION)
+ ktvApiLogError("Error!You can not switch role from $singerRole to $newRole!")
+ }
+ }
+
+ override fun loadMusic(
+ songCode: Long,
+ config: KTVLoadMusicConfiguration,
+ musicLoadStateListener: IMusicLoadStateListener
+ ) {
+ apiReporter.reportFuncEvent("loadMusic", mapOf("songCode" to songCode, "config" to config), mapOf())
+ ktvApiLog("loadMusic called: songCode $songCode")
+
+ val internalSongCode = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ ktvApiLog("loadMusic internalSongCode $internalSongCode")
+ // 设置到全局, 连续调用以最新的为准
+ this.songCode = internalSongCode
+
+ this.songIdentifier = config.songIdentifier
+ this.mainSingerUid = config.mainSingerUid
+ this.needPrelude = config.needPrelude
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+
+ if (config.mode == KTVLoadMusicMode.LOAD_NONE) {
+ return
+ }
+
+ if (config.mode == KTVLoadMusicMode.LOAD_LRC_ONLY) {
+ // 只加载歌词
+ loadLyricAndPitch(config.needPitch, internalSongCode) { song, lyricUrl, pitchUrl ->
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@loadLyricAndPitch
+ }
+
+ if (lyricUrl == null) {
+ // 加载歌词失败
+ ktvApiLogError("loadMusic failed: NO_LYRIC_URL")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.NO_LYRIC_URL)
+ } else {
+ // 加载歌词成功
+ ktvApiLog("loadMusic success")
+ lrcView?.onDownloadLrcData(lyricUrl, pitchUrl)
+ musicLoadStateListener.onMusicLoadSuccess(songCode, lyricUrl)
+ }
+ }
+ return
+ }
+
+ // 预加载歌曲
+ preLoadMusic(internalSongCode) { song, percent, status, msg, lrcUrl ->
+ if (status == MccExState.PRELOAD_STATE_COMPLETED) {
+ // 预加载歌曲成功
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@preLoadMusic
+ }
+ if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_AND_LRC) {
+ // 需要加载歌词
+ loadLyricAndPitch(config.needPitch, song) { _, lyricUrl, pitchUrl ->
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@loadLyricAndPitch
+ }
+
+ if (lyricUrl == null) {
+ // 加载歌词失败
+ ktvApiLogError("loadMusic failed: NO_LYRIC_URL")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.NO_LYRIC_URL)
+ } else {
+ // 加载歌词成功
+ ktvApiLog("loadMusic success")
+ lrcView?.onDownloadLrcData(lyricUrl, pitchUrl)
+ musicLoadStateListener.onMusicLoadProgress(
+ songCode,
+ 100,
+ MusicLoadStatus.COMPLETED,
+ msg,
+ lrcUrl
+ )
+ musicLoadStateListener.onMusicLoadSuccess(songCode, lyricUrl)
+ }
+ }
+ } else if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_ONLY) {
+ // 不需要加载歌词
+ ktvApiLog("loadMusic success")
+ musicLoadStateListener.onMusicLoadProgress(songCode, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl)
+ musicLoadStateListener.onMusicLoadSuccess(songCode, "")
+ }
+ } else if (status == MccExState.PRELOAD_STATE_PRELOADING) {
+ // 预加载歌曲加载中
+ musicLoadStateListener.onMusicLoadProgress(
+ songCode,
+ percent,
+ MusicLoadStatus.INPROGRESS,
+ msg,
+ lrcUrl
+ )
+ } else {
+ // 预加载歌曲失败
+ ktvApiLogError("loadMusic failed: MUSIC_PRELOAD_FAIL $status")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.MUSIC_PRELOAD_FAIL)
+ }
+ }
+ }
+
+ override fun removeMusic(songCode: Long) {
+ apiReporter.reportFuncEvent("removeMusic", mapOf("songCode" to songCode), mapOf())
+// val ret = mMusicCenter.removeCache(songCode)
+// if (ret < 0) {
+// ktvApiLogError("removeMusic failed, ret: $ret")
+// }
+ }
+
+ override fun startScore(
+ songCode: Long,
+ onStartScoreCallback: (songCode: Long, status: MccExState, msg: MccExStateReason) -> Unit
+ ) {
+ val code = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ val ret = mMusicCenter.startScore(code)
+ if (ret < 0) {
+ onStartScoreCallback.invoke(
+ songCode,
+ MccExState.START_SCORE_STATE_FAIL,
+ MccExStateReason.STATE_REASON_ERROR
+ )
+ } else {
+ startScoreMap[code.toString()] = onStartScoreCallback
+ }
+ }
+
+ override fun startSing(songCode: Long, startPos: Long) {
+ apiReporter.reportFuncEvent("startSing", mapOf("songCode" to songCode, "startPos" to startPos), mapOf())
+ ktvApiLog("playSong called: $singerRole")
+
+ val internalSongCode = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) {
+ ktvApiLogError("startSing failed: error singerRole")
+ return
+ }
+
+ if (this.songCode != internalSongCode) {
+ ktvApiLogError("startSing failed: canceled")
+ return
+ }
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+
+ // 导唱
+ mPlayer.setPlayerOption("enable_multi_audio_track", 1)
+ val ret = mPlayer.open(internalSongCode, startPos)
+ if (ret != 0) {
+ ktvApiLogError("mpk open failed: $ret")
+ }
+ }
+
+ override fun resumeSing() {
+ apiReporter.reportFuncEvent("resumeSing", mapOf(), mapOf())
+ mPlayer.resume()
+ }
+
+ override fun pauseSing() {
+ apiReporter.reportFuncEvent("pauseSing", mapOf(), mapOf())
+ mPlayer.pause()
+ }
+
+ override fun seekSing(time: Long) {
+ apiReporter.reportFuncEvent("seekSing", mapOf("time" to time), mapOf())
+ mPlayer.seek(time)
+ syncPlayProgress(time)
+ }
+
+ override fun setLrcView(view: ILrcView) {
+ apiReporter.reportFuncEvent("setLrcView", mapOf("view" to view), mapOf())
+ this.lrcView = view
+ }
+
+ override fun muteMic(mute: Boolean) {
+ apiReporter.reportFuncEvent("muteMic", mapOf("mute" to mute), mapOf())
+ this.isOnMicOpen = !mute
+ if (ktvApiConfig.type != KTVType.SingRelay) {
+ if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) {
+ mRtcEngine.adjustRecordingSignalVolume(if (isOnMicOpen) 100 else 0)
+ if (isOnMicOpen) {
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.publishMicrophoneTrack = true
+ channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+ mRtcEngine.muteLocalAudioStream(!isOnMicOpen)
+ }
+ } else {
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.publishMicrophoneTrack = isOnMicOpen
+ channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+ mRtcEngine.muteLocalAudioStream(!isOnMicOpen)
+ }
+ } else {
+ mRtcEngine.adjustRecordingSignalVolume(if (isOnMicOpen) 100 else 0)
+ }
+ }
+
+ override fun setAudioPlayoutDelay(audioPlayoutDelay: Int) {
+ apiReporter.reportFuncEvent("setAudioPlayoutDelay", mapOf("audioPlayoutDelay" to audioPlayoutDelay), mapOf())
+ this.audioPlayoutDelay = audioPlayoutDelay
+ }
+
+ override fun getMediaPlayer(): IMediaPlayer {
+ return mPlayer
+ }
+
+ // ------------------ inner KTVApi --------------------
+ private fun becomeSoloSinger() {
+ ktvApiLog("becomeSoloSinger called")
+ // 主唱进入合唱模式
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}")
+
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio = true
+ channelMediaOption.publishMediaPlayerId = mPlayer.mediaPlayerId
+ channelMediaOption.publishMediaPlayerAudioTrack = true
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+ }
+
+ private fun joinChorus(newRole: KTVSingRole, token: String, onJoinChorusStateListener: OnJoinChorusStateListener) {
+ ktvApiLog("joinChorus: $newRole")
+ when (newRole) {
+ KTVSingRole.LeadSinger -> {
+ joinChorus2ndChannel(newRole, token, mainSingerUid) { joinStatus ->
+ if (joinStatus == 0) {
+ onJoinChorusStateListener.onJoinChorusSuccess()
+ } else {
+ onJoinChorusStateListener.onJoinChorusFail(KTVJoinChorusFailReason.JOIN_CHANNEL_FAIL)
+ }
+ }
+ }
+
+ KTVSingRole.CoSinger -> {
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio = true
+ channelMediaOption.publishMediaPlayerAudioTrack = false
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+
+ // 预加载歌曲成功
+ if (ktvApiConfig.musicType == KTVMusicType.SONG_CODE) {
+ mPlayer.setPlayerOption("enable_multi_audio_track", 0)
+ val ret = mPlayer.open(songCode, 0) // TODO open failed
+ if (ret != 0) {
+ ktvApiLogError("mpk open failed: $ret")
+ }
+ }
+
+ // 预加载成功后加入第二频道:预加载时间>>joinChannel时间
+ joinChorus2ndChannel(newRole, token, mainSingerUid) { joinStatus ->
+ if (joinStatus == 0) {
+ // 加入第二频道成功
+ onJoinChorusStateListener.onJoinChorusSuccess()
+ } else {
+ // 加入第二频道失败
+ onJoinChorusStateListener.onJoinChorusFail(KTVJoinChorusFailReason.JOIN_CHANNEL_FAIL)
+ }
+ }
+ }
+
+ else -> {
+ ktvApiLogError("JoinChorus with Wrong role: $singerRole")
+ }
+ }
+ }
+
+ private fun leaveChorus(role: KTVSingRole) {
+ ktvApiLog("leaveChorus: $singerRole")
+ when (role) {
+ KTVSingRole.LeadSinger -> {
+ mainSingerHasJoinChannelEx = false
+ leaveChorus2ndChannel(role)
+ }
+
+ KTVSingRole.CoSinger -> {
+ mPlayer.stop()
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.publishMediaPlayerAudioTrack = false
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+ leaveChorus2ndChannel(role)
+
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ }
+
+ else -> {
+ ktvApiLogError("JoinChorus with wrong role: $singerRole")
+ }
+ }
+ }
+
+ private fun stopSing() {
+ ktvApiLog("stopSong called")
+
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.publishMediaPlayerAudioTrack = false
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+
+ mPlayer.stop()
+
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ }
+
+ // ------------------ inner --------------------
+
+ private fun isChorusCoSinger(): Boolean {
+ return singerRole == KTVSingRole.CoSinger
+ }
+
+ private fun sendStreamMessageWithJsonObject(
+ obj: JSONObject,
+ success: (isSendSuccess: Boolean) -> Unit
+ ) {
+ val ret = mRtcEngine.sendStreamMessage(innerDataStreamId, obj.toString().toByteArray())
+ if (ret == 0) {
+ success.invoke(true)
+ } else {
+ ktvApiLogError("sendStreamMessageWithJsonObject failed: $ret")
+ }
+ }
+
+ private fun syncPlayState(
+ state: MediaPlayerState,
+ reason: Constants.MediaPlayerReason
+ ) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "PlayerState"
+ msg["state"] = MediaPlayerState.getValue(state)
+ msg["error"] = Constants.MediaPlayerReason.getValue(reason)
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ private fun syncPlayProgress(time: Long) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "Seek"
+ msg["position"] = time
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ // 合唱
+ private var handlerEx: IRtcEngineEventHandler? = null
+ private fun joinChorus2ndChannel(
+ newRole: KTVSingRole,
+ token: String,
+ mainSingerUid: Int,
+ onJoinChorus2ndChannelCallback: (status: Int?) -> Unit
+ ) {
+ ktvApiLog("joinChorus2ndChannel: token:$token")
+ if (newRole == KTVSingRole.SoloSinger || newRole == KTVSingRole.Audience) {
+ ktvApiLogError("joinChorus2ndChannel with wrong role: $newRole")
+ return
+ }
+
+ if (newRole == KTVSingRole.CoSinger) {
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ }
+
+ // main singer do not subscribe 2nd channel
+ // co singer auto sub
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio =
+ newRole != KTVSingRole.LeadSinger
+ channelMediaOption.autoSubscribeVideo = false
+ channelMediaOption.publishMicrophoneTrack = newRole == KTVSingRole.LeadSinger
+ channelMediaOption.enableAudioRecordingOrPlayout =
+ newRole != KTVSingRole.LeadSinger
+ channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER
+
+ val rtcConnection = RtcConnection()
+ rtcConnection.channelId = ktvApiConfig.chorusChannelName
+ rtcConnection.localUid = ktvApiConfig.localUid
+ subChorusConnection = rtcConnection
+
+ val ret = mRtcEngine.joinChannelEx(
+ token,
+ rtcConnection,
+ channelMediaOption,
+ null
+ )
+ val handler = object : IRtcEngineEventHandler() {
+ override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
+ if (isRelease) return
+ ktvApiLog("onJoinChannel2Success: channel:$channel, uid:$uid")
+ super.onJoinChannelSuccess(channel, uid, elapsed)
+ if (newRole == KTVSingRole.LeadSinger) {
+ mainSingerHasJoinChannelEx = true
+ }
+ onJoinChorus2ndChannelCallback(0)
+ mRtcEngine.enableAudioVolumeIndicationEx(50, 10, true, rtcConnection)
+ }
+
+ override fun onLeaveChannel(stats: RtcStats?) {
+ if (isRelease) return
+ //ktvApiLog("onLeaveChannel2")
+ super.onLeaveChannel(stats)
+ if (newRole == KTVSingRole.LeadSinger) {
+ mainSingerHasJoinChannelEx = false
+ }
+ }
+
+ override fun onError(err: Int) {
+ super.onError(err)
+ if (isRelease) return
+ if (err == ERR_JOIN_CHANNEL_REJECTED) {
+ ktvApiLogError("joinChorus2ndChannel failed: ERR_JOIN_CHANNEL_REJECTED")
+ onJoinChorus2ndChannelCallback(ERR_JOIN_CHANNEL_REJECTED)
+ } else if (err == ERR_LEAVE_CHANNEL_REJECTED) {
+ ktvApiLogError("leaveChorus2ndChannel failed: ERR_LEAVE_CHANNEL_REJECTED")
+ }
+ }
+
+ override fun onTokenPrivilegeWillExpire(token: String?) {
+ super.onTokenPrivilegeWillExpire(token)
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+
+ override fun onAudioVolumeIndication(
+ speakers: Array?,
+ totalVolume: Int
+ ) {
+ super.onAudioVolumeIndication(speakers, totalVolume)
+ ktvApiEventHandlerList.forEach { it.onChorusChannelAudioVolumeIndication(speakers, totalVolume) }
+ }
+ }
+ handlerEx = handler
+ mRtcEngine.addHandlerEx(handler, rtcConnection)
+ if (enableMultipathing) {
+ mRtcEngine.setParametersEx(
+ "{\"rtc.path_scheduling_strategy\":0, \"rtc.enableMultipath\": true, \"rtc.remote_path_scheduling_strategy\": 0}",
+ rtcConnection
+ )
+ }
+ if (ret != 0) {
+ ktvApiLogError("joinChorus2ndChannel failed: $ret")
+ }
+
+ if (newRole == KTVSingRole.CoSinger) {
+ mRtcEngine.muteRemoteAudioStream(mainSingerUid, true)
+ ktvApiLog("muteRemoteAudioStream$mainSingerUid")
+ }
+ }
+
+ private fun leaveChorus2ndChannel(role: KTVSingRole) {
+ mRtcEngine.removeHandlerEx(handlerEx, subChorusConnection)
+ if (role == KTVSingRole.LeadSinger) {
+ mRtcEngine.leaveChannelEx(subChorusConnection)
+ } else if (role == KTVSingRole.CoSinger) {
+ mRtcEngine.leaveChannelEx(subChorusConnection)
+ mRtcEngine.muteRemoteAudioStream(mainSingerUid, false)
+ }
+ }
+
+ // ------------------ 同步新主唱 --------------------
+ private fun syncNewLeadSinger(uid: Int) {
+ val msg: MutableMap = java.util.HashMap()
+ msg["cmd"] = "syncNewLeadSinger"
+ msg["uid"] = uid
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ // ------------------ 歌词播放、同步 ------------------
+ private fun startDisplayLrc() {
+ ktvApiLog("startDisplayLrc called")
+ mStopDisplayLrc = false
+ displayLrcFuture = scheduledThreadPool.scheduleAtFixedRate(displayLrcTask, 0, 20, TimeUnit.MILLISECONDS)
+ }
+
+ // 停止播放歌词
+ private fun stopDisplayLrc() {
+ ktvApiLog("stopDisplayLrc called")
+ mStopDisplayLrc = true
+ displayLrcFuture?.cancel(true)
+ displayLrcFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(displayLrcTask)
+ }
+ }
+
+ // ------------------ 音高pitch同步 ------------------
+ private fun sendSyncPitch(pitch: Double, progressInMs: Int) {
+ val msg: MutableMap = java.util.HashMap()
+ msg["cmd"] = "setVoicePitch"
+ msg["pitch"] = pitch
+ msg["progressInMs"] = progressInMs
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ // 开始同步音高
+ private fun startSyncPitch() {
+ mStopSyncPitch = false
+ mSyncPitchFuture = scheduledThreadPool.scheduleAtFixedRate(mSyncPitchTask, 0, 50, TimeUnit.MILLISECONDS)
+ }
+
+ // 停止同步音高
+ private fun stopSyncPitch() {
+ mStopSyncPitch = true
+ pitch = 0.0
+ progressInMs = 0
+
+ mSyncPitchFuture?.cancel(true)
+ mSyncPitchFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(mSyncPitchTask)
+ }
+ }
+
+ /**
+ * Load lyric and pitch
+ *
+ * @param needPitch
+ * @param songNo
+ * @param onLoadCallback
+ * @receiver
+ */
+ private fun loadLyricAndPitch(
+ needPitch: Boolean,
+ songNo: Long,
+ onLoadCallback: (songNo: Long, lyricUrl: String?, pitchUrl: String?) -> Unit
+ ) {
+ if (needPitch) {
+ loadLyric(songNo) { song, lyric ->
+ loadPitch(songNo) { _, pitch ->
+ onLoadCallback.invoke(song, lyric, pitch)
+ }
+ }
+ } else {
+ loadLyric(songNo) { song, lyric ->
+ onLoadCallback.invoke(song, lyric, null)
+ }
+ }
+ }
+
+ private fun loadLyric(songNo: Long, onLoadLyricCallback: (songNo: Long, lyricUrl: String?) -> Unit) {
+ ktvApiLog("loadLyric: $songNo")
+ val requestId = mMusicCenter.getLyric(songNo, LyricType.KRC)
+ if (requestId.isEmpty()) {
+ onLoadLyricCallback.invoke(songNo, null)
+ return
+ }
+ lyricSongCodeMap[requestId] = songNo
+ lyricCallbackMap[requestId] = onLoadLyricCallback
+ }
+
+ private fun loadPitch(
+ songNo: Long,
+ onLoadPitchCallback: (songNo: Long, pitchUrl: String?) -> Unit
+ ) {
+ ktvApiLog("loadPitch: $songNo")
+ val requestId = mMusicCenter.getPitch(songNo)
+ if (requestId.isEmpty()) {
+ onLoadPitchCallback.invoke(songNo, null)
+ return
+ }
+ pitchCallbackMap[requestId] = onLoadPitchCallback
+ }
+
+ private fun preLoadMusic(
+ songNo: Long, onLoadMusicCallback: (
+ songCode: Long,
+ percent: Int,
+ status: MccExState,
+ msg: String?,
+ lyricUrl: String?
+ ) -> Unit
+ ) {
+ ktvApiLog("loadMusic: $songNo")
+ val ret = mMusicCenter.isPreloaded(songNo)
+ if (ret == 0) {
+ loadMusicCallbackMap.remove(songNo.toString())
+ onLoadMusicCallback(songNo, 100, MccExState.PRELOAD_STATE_COMPLETED, null, null)
+ return
+ }
+
+ val retPreload = mMusicCenter.preload(songNo)
+ if (retPreload == "") {
+ ktvApiLogError("preLoadMusic failed: $retPreload")
+ loadMusicCallbackMap.remove(songNo.toString())
+ onLoadMusicCallback(songNo, 100, MccExState.PRELOAD_STATE_FAILED, null, null)
+ return
+ }
+ loadMusicCallbackMap[songNo.toString()] = onLoadMusicCallback
+ }
+
+ private fun getNtpTimeInMs(): Long {
+ val currentNtpTime = mRtcEngine.ntpWallTimeInMs
+ return if (currentNtpTime != 0L) {
+ currentNtpTime + 2208988800L * 1000
+ } else {
+ ktvApiLogError("getNtpTimeInMs DeviceDelay is zero!!!")
+ System.currentTimeMillis()
+ }
+ }
+
+ private fun runOnMainThread(r: Runnable) {
+ if (Thread.currentThread() == mainHandler.looper.thread) {
+ r.run()
+ } else {
+ mainHandler.post(r)
+ }
+ }
+
+ // ------------------------ AgoraRtcEvent ------------------------
+ override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ super.onStreamMessage(uid, streamId, data)
+ if (uid != mainSingerUid) return
+ dealWithStreamMessage(data)
+ }
+
+ override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) {
+ super.onAudioMetadataReceived(uid, data)
+ val messageData = data ?: return
+ try {
+
+ val lrcTime = LrcTimeOuterClass.LrcTime.parseFrom(messageData)
+ if (lrcTime.type == LrcTimeOuterClass.MsgType.LRC_TIME) { //同步歌词
+ val realPosition = lrcTime.ts
+ val songId = lrcTime.songId
+ val curTs = if (this.songIdentifier == songId) realPosition else 0
+ runOnMainThread {
+ lrcView?.onUpdatePitch(songCode, pitch, progressInMs)
+ // (fix ENT-489)Make lyrics delay for 200ms
+ // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`,
+ // such as AEC/Player/Device buffer.
+ // We choose the estimated 200ms.
+ lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side
+ }
+ }
+ } catch (exp: JSONException) {
+ ktvApiLog("onStreamMessage:$exp")
+ }
+ }
+
+ private fun dealWithStreamMessage(data: ByteArray?) {
+ val jsonMsg: JSONObject
+ val messageData = data ?: return
+ try {
+ val strMsg = String(messageData)
+ jsonMsg = JSONObject(strMsg)
+ if (jsonMsg.getString("cmd") == "setLrcTime") { //同步歌词
+ val position = jsonMsg.getLong("time")
+ val realPosition = jsonMsg.getLong("realTime")
+ val duration = jsonMsg.getLong("duration")
+ val remoteNtp = jsonMsg.getLong("ntp")
+ val songId = jsonMsg.getString("songIdentifier")
+ val mpkState = jsonMsg.getInt("playerState")
+
+ if (isChorusCoSinger()) {
+ // 本地BGM校准逻辑
+ if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED) {
+ // 合唱者开始播放音乐前调小远端人声
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+ // 收到leadSinger第一次播放位置消息时开启本地播放(先通过seek校准)
+ val delta = getNtpTimeInMs() - remoteNtp
+ val expectPosition = position + delta + audioPlayoutDelay
+ if (expectPosition in 1 until duration) {
+ mPlayer.seek(expectPosition)
+ }
+ mPlayer.play()
+ } else if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING) {
+ val localNtpTime = getNtpTimeInMs()
+ val localPosition =
+ localNtpTime - this.localPlayerSystemTime + this.localPlayerPosition // 当前副唱的播放时间
+ val expectPosition =
+ localNtpTime - remoteNtp + position + audioPlayoutDelay // 实际主唱的播放时间
+ val diff = expectPosition - localPosition
+ if (KTVApi.debugMode) {
+ ktvApiLog(
+ "play_status_seek: " + diff + " audioPlayoutDelay:" + audioPlayoutDelay + " localNtpTime: " + localNtpTime + " expectPosition: " + expectPosition +
+ " localPosition: " + localPosition + " ntp diff: " + (localNtpTime - remoteNtp)
+ )
+ }
+ if ((diff > 50 || diff < -50) && expectPosition < duration) { //设置阈值为50ms,避免频繁seek
+ ktvApiLog("player seek: $diff")
+ mPlayer.seek(expectPosition)
+ }
+ } else {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = realPosition
+ }
+
+ if (MediaPlayerState.getStateByValue(mpkState) != this.mediaPlayerState) {
+ when (MediaPlayerState.getStateByValue(mpkState)) {
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mPlayer.pause()
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mPlayer.resume()
+ }
+
+ else -> {}
+ }
+ }
+ } else {
+ // 独唱观众
+ if (jsonMsg.has("ver")) {
+ // 发送端是新发送端, 歌词信息需要从 audioMetadata 里取
+ recvFromDataStream = false
+ } else {
+ // 发送端是老发送端, 歌词信息需要从 dataStreamMessage 里取
+ recvFromDataStream = true
+ if (this.songIdentifier == songId) {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = realPosition
+ ktvApiEventHandlerList.forEach { it.onMusicPlayerPositionChanged(realPosition, 0) }
+ } else {
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+ }
+ }
+ }
+ } else if (jsonMsg.getString("cmd") == "Seek") {
+ // 伴唱收到原唱seek指令
+ if (isChorusCoSinger()) {
+ val position = jsonMsg.getLong("position")
+ mPlayer.seek(position)
+ }
+ } else if (jsonMsg.getString("cmd") == "PlayerState") {
+ // 其他端收到原唱seek指令
+ val state = jsonMsg.getInt("state")
+ val error = jsonMsg.getInt("error")
+ if (isChorusCoSinger()) {
+ when (MediaPlayerState.getStateByValue(state)) {
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mPlayer.pause()
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mPlayer.resume()
+ }
+
+ else -> {}
+ }
+ } else if (this.singerRole == KTVSingRole.Audience) {
+ this.mediaPlayerState = MediaPlayerState.getStateByValue(state)
+ }
+ ktvApiEventHandlerList.forEach {
+ it.onMusicPlayerStateChanged(
+ MediaPlayerState.getStateByValue(state),
+ Constants.MediaPlayerReason.getErrorByValue(error),
+ false
+ )
+ }
+ } else if (jsonMsg.getString("cmd") == "setVoicePitch") {
+ val pitch = jsonMsg.getDouble("pitch")
+ val progressInMs = jsonMsg.getInt("progressInMs")
+ if (ktvApiConfig.type == KTVType.SingRelay && !isOnMicOpen && this.singerRole != KTVSingRole.Audience) {
+ this.pitch = pitch
+ this.progressInMs = progressInMs
+ }
+ if (this.singerRole == KTVSingRole.Audience) {
+ this.pitch = pitch
+ this.progressInMs = progressInMs
+ }
+ } else if (jsonMsg.getString("cmd") == "syncNewLeadSinger") {
+ if (singerRole == KTVSingRole.CoSinger) {
+ mRtcEngine.muteRemoteAudioStream(mainSingerUid, false)
+ mainSingerUid = jsonMsg.getInt("uid")
+ mRtcEngine.muteRemoteAudioStream(mainSingerUid, true)
+ }
+ }
+ } catch (_: JSONException) {
+ }
+ }
+
+ override fun onAudioVolumeIndication(speakers: Array?, totalVolume: Int) {
+ super.onAudioVolumeIndication(speakers, totalVolume)
+ val allSpeakers = speakers ?: return
+ // VideoPitch 回调, 用于同步各端音准
+ if (this.ktvApiConfig.type == KTVType.SingRelay && !isOnMicOpen) {
+ return
+ }
+ // TODO: 2024/10/17 音高 maccEx 回调
+// if (this.singerRole != KTVSingRole.Audience) {
+// for (info in allSpeakers) {
+// if (info.uid == 0) {
+// pitch =
+// if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && isOnMicOpen) {
+// info.voicePitch
+// } else {
+// 0.0
+// }
+// }
+// }
+// }
+ }
+
+ // 用于合唱校准
+ override fun onLocalAudioStats(stats: LocalAudioStats?) {
+ super.onLocalAudioStats(stats)
+ if (KTVApi.useCustomAudioSource) return
+ val audioState = stats ?: return
+ audioPlayoutDelay = audioState.audioPlayoutDelay
+ }
+
+ // 用于检测耳机状态
+ override fun onAudioRouteChanged(routing: Int) { // 0\2\5 earPhone
+ super.onAudioRouteChanged(routing)
+ this.audioRouting = routing
+ processAudioProfessionalProfile()
+ }
+
+ // 用于检测收发流状态
+ override fun onAudioPublishStateChanged(
+ channel: String?,
+ oldState: Int,
+ newState: Int,
+ elapseSinceLastState: Int
+ ) {
+ super.onAudioPublishStateChanged(channel, oldState, newState, elapseSinceLastState)
+ if (newState == 3) {
+ this.isPublishAudio = true
+ processAudioProfessionalProfile()
+ } else if (newState == 1) {
+ this.isPublishAudio = false
+ }
+ }
+
+ // ------------------------ IMusicContentCenterExEventHandler ------------------------
+ override fun onInitializeResult(state: MccExState, reason: MccExStateReason) {
+ ktvApiLog(
+ "onInitializeResult, state:$state, reason:$reason"
+ )
+ }
+
+ override fun onPreLoadEvent(
+ requestId: String,
+ songCode: Long,
+ percent: Int,
+ lyricPath: String,
+ pitchPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ lyricOffset: Int,
+ state: MccExState,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog(
+ "onPreLoadEvent, requestId:$requestId, songCode:$songCode, percent:$percent, " +
+ "lyricPath:$lyricPath, pitchPath:$pitchPath, state:$state, reason:$reason"
+ )
+
+ val callback = loadMusicCallbackMap[songCode.toString()] ?: return
+ if (state == MccExState.PRELOAD_STATE_COMPLETED || state == MccExState.PRELOAD_STATE_FAILED || state == MccExState.PRELOAD_STATE_REMOVED) {
+ loadMusicCallbackMap.remove(songCode.toString())
+ }
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ callback.invoke(songCode, percent, state, reason.reason.toString(), lyricPath)
+ }
+
+ override fun onStartScoreResult(songCode: Long, state: MccExState, reason: MccExStateReason) {
+ ktvApiLog("onStartScoreResult, songCode:$songCode, state:$state, reason:$reason")
+ val callback = startScoreMap.remove(songCode.toString())
+ callback?.invoke(songCode, state, reason)
+ }
+
+ override fun onLyricResult(
+ requestId: String,
+ songCode: Long,
+ lyricPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ lyricOffset: Int,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog("onLyricResult, requestId:$requestId, songCode:$songCode, lyricPath:$lyricPath, reason:$reason")
+ val callback = lyricCallbackMap[requestId] ?: return
+ val songCode = lyricSongCodeMap[requestId] ?: return
+ lyricCallbackMap.remove(requestId)
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ if (lyricPath.isEmpty()) {
+ callback(songCode, null)
+ return
+ }
+ callback(songCode, lyricPath)
+ }
+
+ override fun onPitchResult(
+ requestId: String,
+ songCode: Long,
+ pitchPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog("onPitchResult, requestId:$requestId, songCode:$songCode, pitchPath:$pitchPath, reason:$reason")
+ val callback = pitchCallbackMap[requestId] ?: return
+ pitchCallbackMap.remove(requestId)
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ if (pitchPath.isEmpty()) {
+ callback(songCode, null)
+ return
+ }
+ callback(songCode, pitchPath)
+ }
+
+ // IMusicContentCenterExScoreEventHandler
+ override fun onLineScore(songCode: Long, value: LineScoreData) {
+ runOnMainThread {
+ lrcView?.onLineScore(songCode, value)
+ }
+ }
+
+
+ override fun onPitch(songCode: Long, data: RawScoreData) {
+ if (this.singerRole != KTVSingRole.Audience) {
+ this.pitch = data.speakerPitch.toDouble()
+ this.progressInMs = data.progressInMs
+ }
+ }
+
+ // ------------------------ AgoraRtcMediaPlayerDelegate ------------------------
+ private var duration: Long = 0
+ override fun onPlayerStateChanged(
+ state: MediaPlayerState?,
+ reason: Constants.MediaPlayerReason?
+ ) {
+ val mediaPlayerState = state ?: return
+ val mediaPlayerError = reason ?: return
+ ktvApiLog("onPlayerStateChanged: $state")
+ this.mediaPlayerState = mediaPlayerState
+ when (mediaPlayerState) {
+ MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED -> {
+ duration = mPlayer.duration
+ this.localPlayerPosition = 0
+ // 伴奏
+ if (this.singerRole == KTVSingRole.SoloSinger ||
+ this.singerRole == KTVSingRole.LeadSinger
+ ) {
+ mPlayer.selectMultiAudioTrack(1, 1)
+ mPlayer.play()
+ } else {
+ mPlayer.selectAudioTrack(1)
+ }
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+ }
+
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mRtcEngine.adjustPlaybackSignalVolume(100)
+ }
+
+ MediaPlayerState.PLAYER_STATE_STOPPED -> {
+ mRtcEngine.adjustPlaybackSignalVolume(100)
+ duration = 0
+ }
+
+ else -> {}
+ }
+
+ if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) {
+ syncPlayState(mediaPlayerState, mediaPlayerError)
+ }
+ ktvApiEventHandlerList.forEach { it.onMusicPlayerStateChanged(mediaPlayerState, mediaPlayerError, true) }
+ }
+
+ // 同步播放进度
+ override fun onPositionChanged(position_ms: Long, timestamp_ms: Long) {
+ localPlayerPosition = position_ms
+ localPlayerSystemTime = timestamp_ms
+
+ if ((this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) && position_ms > audioPlayoutDelay) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "setLrcTime"
+ msg["ntp"] = timestamp_ms
+ msg["duration"] = duration
+ msg["time"] =
+ position_ms - audioPlayoutDelay // "position-audioDeviceDelay" 是计算出当前播放的真实进度
+ msg["realTime"] = position_ms
+ msg["playerState"] = MediaPlayerState.getValue(this.mediaPlayerState)
+ msg["pitch"] = pitch
+ msg["progressInMs"] = progressInMs
+ msg["songIdentifier"] = songIdentifier
+ msg["ver"] = lyricSyncVersion
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ if (this.singerRole != KTVSingRole.Audience) {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = position_ms
+ } else {
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+ }
+
+ ktvApiEventHandlerList.forEach { it.onMusicPlayerPositionChanged(position_ms, timestamp_ms) }
+ }
+
+ override fun onPlayerEvent(
+ eventCode: Constants.MediaPlayerEvent?,
+ elapsedTime: Long,
+ message: String?
+ ) {
+ }
+
+ override fun onMetaData(type: Constants.MediaPlayerMetadataType?, data: ByteArray?) {}
+
+ override fun onPlayBufferUpdated(playCachedBuffer: Long) {}
+
+ override fun onPreloadEvent(src: String?, event: Constants.MediaPlayerPreloadEvent?) {
+ }
+
+ override fun onAgoraCDNTokenWillExpire() {}
+
+ override fun onPlayerSrcInfoChanged(from: SrcInfo?, to: SrcInfo?) {}
+
+ override fun onPlayerInfoUpdated(info: PlayerUpdatedInfo?) {}
+
+ override fun onPlayerCacheStats(stats: CacheStatistics?) {}
+
+ override fun onPlayerPlaybackStats(stats: PlayerPlaybackStats?) {}
+
+ override fun onAudioVolumeIndication(volume: Int) {}
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVGiantChorusApiImpl.kt b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVGiantChorusApiImpl.kt
new file mode 100644
index 0000000..9765323
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/KTVGiantChorusApiImpl.kt
@@ -0,0 +1,1660 @@
+package io.agora.ktvapiex
+
+import android.os.Handler
+import android.os.Looper
+import io.agora.mccex.IMusicContentCenterEx
+import io.agora.mccex.IMusicContentCenterExEventHandler
+import io.agora.mccex.IMusicContentCenterExScoreEventHandler
+import io.agora.mccex.IMusicPlayer
+import io.agora.mccex.constants.LyricType
+import io.agora.mccex.constants.MccExState
+import io.agora.mccex.constants.MccExStateReason
+import io.agora.mccex.model.LineScoreData
+import io.agora.mccex.model.RawScoreData
+import io.agora.mediaplayer.Constants
+import io.agora.mediaplayer.Constants.MediaPlayerState
+import io.agora.mediaplayer.IMediaPlayer
+import io.agora.mediaplayer.IMediaPlayerObserver
+import io.agora.mediaplayer.data.CacheStatistics
+import io.agora.mediaplayer.data.PlayerPlaybackStats
+import io.agora.mediaplayer.data.PlayerUpdatedInfo
+import io.agora.mediaplayer.data.SrcInfo
+import io.agora.rtc2.*
+import io.agora.rtc2.Constants.*
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.concurrent.*
+
+class KTVGiantChorusApiImpl : KTVApi, IMediaPlayerObserver,
+ IMusicContentCenterExEventHandler, IMusicContentCenterExScoreEventHandler,
+ IRtcEngineEventHandler() {
+
+ companion object {
+ private val scheduledThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(5)
+ private const val tag = "KTV_API_LOG_GIANT"
+ private const val version = "5.0.0"
+ private const val lyricSyncVersion = 2
+ }
+
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
+ private lateinit var mRtcEngine: RtcEngineEx
+ private lateinit var mMusicCenter: IMusicContentCenterEx
+ private lateinit var mPlayer: IMusicPlayer
+
+ private lateinit var giantChorusApiConfig: KTVGiantChorusApiConfig
+ private lateinit var apiReporter: APIReporter
+
+ private var innerDataStreamId: Int = 0
+ private var singChannelRtcConnection: RtcConnection? = null
+ private var subChorusConnection: RtcConnection? = null
+ private var mpkConnection: RtcConnection? = null
+
+ private var mainSingerUid: Int = 0
+ private var songCode: Long = 0
+ private var songIdentifier: String = ""
+
+ private val lyricCallbackMap =
+ mutableMapOf Unit>() // (requestId, callback)
+ private val pitchCallbackMap =
+ mutableMapOf Unit>() // (requestId, callback)
+ private val lyricSongCodeMap = mutableMapOf() // (requestId, songCode)
+ private val startScoreMap =
+ mutableMapOf Unit>() // (songNo, callback)
+ private val loadMusicCallbackMap =
+ mutableMapOf Unit>() // (songNo, callback)
+
+ private var lrcView: ILrcView? = null
+
+ private var localPlayerPosition: Long = 0
+ private var localPlayerSystemTime: Long = 0
+
+ //歌词实时刷新
+ private var mReceivedPlayPosition: Long = 0 //播放器播放position,ms
+ private var mLastReceivedPlayPosTime: Long? = null
+
+ // event
+ private var ktvApiEventHandlerList = mutableListOf()
+ private var mainSingerHasJoinChannelEx: Boolean = false
+
+ // 合唱校准
+ private var audioPlayoutDelay = 0
+
+ // 音高
+ private var pitch = 0.0
+ private var progressInMs = 0
+
+ // 是否在麦上
+ private var isOnMicOpen = false
+ private var isRelease = false
+
+ // mpk状态
+ private var mediaPlayerState: MediaPlayerState = MediaPlayerState.PLAYER_STATE_IDLE
+
+ private var professionalModeOpen = false
+ private var audioRouting = 0
+ private var isPublishAudio = false // 通过是否发音频流判断
+
+ // 演唱分数
+ private var singingScore = 0
+
+ // multipath
+ private var enableMultipathing = true
+
+ // 歌词信息是否来源于 dataStream
+ private var recvFromDataStream = false
+
+ // 开始播放歌词
+ private var mStopDisplayLrc = true
+ private var displayLrcFuture: ScheduledFuture<*>? = null
+ private val displayLrcTask = object : Runnable {
+ override fun run() {
+ if (!mStopDisplayLrc) {
+ if (singerRole == KTVSingRole.Audience && !recvFromDataStream) return // audioMetaData方案观众return
+ val lastReceivedTime = mLastReceivedPlayPosTime ?: return
+ val curTime = System.currentTimeMillis()
+ val offset = curTime - lastReceivedTime
+ if (offset <= 100) {
+ val curTs = mReceivedPlayPosition + offset
+ if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger) {
+ val lrcTime = LrcTimeOuterClass.LrcTime.newBuilder()
+ .setTypeValue(LrcTimeOuterClass.MsgType.LRC_TIME.number)
+ .setForward(true)
+ .setSongId(songIdentifier)
+ .setTs(curTs)
+ .setUid(giantChorusApiConfig.musicStreamUid)
+ .build()
+
+ mRtcEngine.sendAudioMetadataEx(lrcTime.toByteArray(), mpkConnection)
+ }
+ runOnMainThread {
+ lrcView?.onUpdatePitch(songCode, pitch, progressInMs)
+ // (fix ENT-489)Make lyrics delay for 200ms
+ // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`,
+ // such as AEC/Player/Device buffer.
+ // We choose the estimated 200ms.
+ lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side
+ }
+ }
+ }
+ }
+ }
+
+ // 评分驱动混音
+ private var mSyncScoreFuture: ScheduledFuture<*>? = null
+ private var mStopSyncScore = true
+ private val mSyncScoreTask = Runnable {
+ if (!mStopSyncScore) {
+ if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING &&
+ (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger)
+ ) {
+ sendSyncScore()
+ }
+ }
+ }
+
+ // 云端合流信息
+ private var mSyncCloudConvergenceStatusFuture: ScheduledFuture<*>? = null
+ private var mStopSyncCloudConvergenceStatus = true
+ private val mSyncCloudConvergenceStatusTask = Runnable {
+ if (!mStopSyncCloudConvergenceStatus && singerRole == KTVSingRole.LeadSinger) {
+ sendSyncCloudConvergenceStatus()
+ }
+ }
+
+ override fun initialize(ktvConfig: KTVConfig) {
+ val config = ktvConfig as KTVGiantChorusApiConfig
+ this.mRtcEngine = config.engine as RtcEngineEx
+ this.apiReporter = APIReporter(APIType.KTV, KTVApiImpl.version, mRtcEngine)
+ this.giantChorusApiConfig = config
+ apiReporter.reportFuncEvent("initialize", mapOf("config" to giantChorusApiConfig.toString()), mapOf())
+
+ this.singChannelRtcConnection =
+ RtcConnection(giantChorusApiConfig.chorusChannelName, giantChorusApiConfig.localUid)
+
+ mMusicCenter = config.mMusicCenter
+ mMusicCenter.registerEventHandler(this)
+ mMusicCenter.registerScoreEventHandler(this)
+
+ // ------------------ 初始化音乐播放器实例 ------------------
+ mPlayer = mMusicCenter.createMusicPlayer()!!
+ mPlayer.adjustPublishSignalVolume(KTVApi.mpkPublishVolume)
+ mPlayer.adjustPlayoutVolume(KTVApi.mpkPlayoutVolume)
+
+ // 注册回调
+ mRtcEngine.addHandler(this)
+ mPlayer.registerPlayerObserver(this)
+ mMusicCenter.registerEventHandler(this)
+
+ renewInnerDataStreamId() //初始化innerDataStreamId
+ setKTVParameters()
+ startDisplayLrc()
+ startSyncScore()
+ startSyncCloudConvergenceStatus()
+ isRelease = false
+
+ mPlayer.setPlayerOption("play_pos_change_callback", 100)
+ }
+
+ // 日志输出
+ private fun ktvApiLog(msg: String) {
+ if (isRelease) return
+ apiReporter.writeLog("[${tag}] $msg", LOG_LEVEL_INFO)
+ }
+
+ // 日志输出
+ private fun ktvApiLogError(msg: String) {
+ if (isRelease) return
+ apiReporter.writeLog("[${tag}] $msg", LOG_LEVEL_ERROR)
+ }
+
+ override fun renewInnerDataStreamId() {
+ apiReporter.reportFuncEvent("renewInnerDataStreamId", mapOf(), mapOf())
+
+ val innerCfg = DataStreamConfig()
+ innerCfg.syncWithAudio = true
+ innerCfg.ordered = false
+ this.innerDataStreamId = mRtcEngine.createDataStreamEx(innerCfg, singChannelRtcConnection)
+ }
+
+ private fun setKTVParameters() {
+ mRtcEngine.setParameters("{\"rtc.enable_nasa2\": true}")
+ mRtcEngine.setParameters("{\"rtc.ntp_delay_drop_threshold\":1000}")
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": true}")
+ mRtcEngine.setParameters("{\"rtc.net.maxS2LDelay\": 800}")
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}")
+
+ mRtcEngine.setParameters("{\"rtc.net.maxS2LDelayBroadcast\":400}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\":600}")
+ mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 8}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ mRtcEngine.setParameters("{\"che.audio.uplink_apm_async_process\": true}")
+
+ // 标准音质
+ mRtcEngine.setParameters("{\"che.audio.aec.split_srate_for_48k\": 16000}")
+
+ // ENT-901
+ mRtcEngine.setParameters("{\"che.audio.ans.noise_gate\": 20}")
+
+ // Android Only
+ mRtcEngine.setParameters("{\"che.audio.enable_estimated_device_delay\":false}")
+
+ // TopN + SendAudioMetadata
+ mRtcEngine.setParameters("{\"rtc.use_audio4\": true}")
+
+ // mutipath
+ enableMultipathing = false
+ //mRtcEngine.setParameters("{\"rtc.enableMultipath\": true}")
+ mRtcEngine.setParameters("{\"rtc.enable_tds_request_on_join\": true}")
+ //mRtcEngine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}")
+ //mRtcEngine.setParameters("{\"rtc.path_scheduling_strategy\": 0}")
+
+ // 数据上报
+ mRtcEngine.setParameters("{\"rtc.direct_send_custom_event\": true}")
+ }
+
+ private fun resetParameters() {
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}") // 兼容之前的profile = 3设置
+ mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 3}") // 正常3路下行流混流
+ mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\": false}") // 关闭 接收端快速对齐模式
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": false}") // 观众关闭 多端同步
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": false}") //主播关闭多端同步
+ }
+
+ override fun addEventHandler(ktvApiEventHandler: IKTVApiEventHandler) {
+ apiReporter.reportFuncEvent("addEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf())
+ ktvApiEventHandlerList.add(ktvApiEventHandler)
+ }
+
+ override fun removeEventHandler(ktvApiEventHandler: IKTVApiEventHandler) {
+ apiReporter.reportFuncEvent("removeEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf())
+ ktvApiEventHandlerList.remove(ktvApiEventHandler)
+ }
+
+ override fun release() {
+ apiReporter.reportFuncEvent("release", mapOf(), mapOf())
+ if (isRelease) return
+ isRelease = true
+ singerRole = KTVSingRole.Audience
+
+ resetParameters()
+ stopSyncCloudConvergenceStatus()
+ stopSyncScore()
+ stopDisplayLrc()
+ this.mLastReceivedPlayPosTime = null
+ this.mReceivedPlayPosition = 0
+ this.innerDataStreamId = 0
+ this.singingScore = 0
+ this.mainSingerUid = 0
+
+ lyricCallbackMap.clear()
+ pitchCallbackMap.clear()
+ loadMusicCallbackMap.clear()
+ lyricCallbackMap.clear()
+ startScoreMap.clear()
+ lrcView = null
+
+ mPlayer.unRegisterPlayerObserver(this)
+
+ mRtcEngine.removeHandler(this)
+ mPlayer.unRegisterPlayerObserver(this)
+ mMusicCenter.unregisterEventHandler()
+
+ mPlayer.stop()
+ mPlayer.destroy()
+ IMusicContentCenterEx.destroy()
+
+ mainSingerHasJoinChannelEx = false
+ professionalModeOpen = false
+ audioRouting = 0
+ isPublishAudio = false
+ }
+
+ override fun enableProfessionalStreamerMode(enable: Boolean) {
+ apiReporter.reportFuncEvent("enableProfessionalStreamerMode", mapOf("enable" to enable), mapOf())
+ this.professionalModeOpen = enable
+ processAudioProfessionalProfile()
+ }
+
+ private fun processAudioProfessionalProfile() {
+ ktvApiLog("processAudioProfessionalProfile: audioRouting: $audioRouting, professionalModeOpen: $professionalModeOpen, isPublishAudio:$isPublishAudio")
+ if (!isPublishAudio) return // 必须为麦上者
+ if (professionalModeOpen) {
+ // 专业
+ if (audioRouting == AUDIO_ROUTE_HEADSET || audioRouting == AUDIO_ROUTE_HEADSETNOMIC || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP || audioRouting == AUDIO_ROUTE_USBDEVICE || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_A2DP) {
+ // 耳机 关闭3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": false}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo
+ } else {
+ // 非耳机 开启3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo
+ }
+ } else {
+ // 非专业 开启3A 关闭md
+ mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}")
+ mRtcEngine.setParameters("{\"che.audio.md.enable\": false}")
+ mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_STANDARD_STEREO) // AgoraAudioProfileMusicStandardStereo
+ }
+ }
+
+ override fun enableMulitpathing(enable: Boolean) {
+ apiReporter.reportFuncEvent("enableMulitpathing", mapOf("enable" to enable), mapOf())
+ this.enableMultipathing = enable
+
+ if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger) {
+ subChorusConnection?.let {
+ mRtcEngine.setParametersEx(
+ "{\"rtc.enableMultipath\": $enable, \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}",
+ it
+ )
+ }
+ }
+ }
+
+ override fun renewToken(rtmToken: String, chorusChannelRtcToken: String) {
+ apiReporter.reportFuncEvent("renewToken", mapOf(), mapOf())
+ // 更新RtmToken
+ mMusicCenter.renewToken(rtmToken)
+ // 更新合唱频道RtcToken
+ if (subChorusConnection != null) {
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.token = chorusChannelRtcToken
+ mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption, subChorusConnection)
+ }
+ }
+
+ // 1、Audience -》SoloSinger
+ // 2、Audience -》LeadSinger
+ // 3、SoloSinger -》Audience
+ // 4、Audience -》CoSinger
+ // 5、CoSinger -》Audience
+ // 6、SoloSinger -》LeadSinger
+ // 7、LeadSinger -》SoloSinger
+ // 8、LeadSinger -》Audience
+ var singerRole: KTVSingRole = KTVSingRole.Audience
+
+ override fun switchSingerRole(
+ newRole: KTVSingRole,
+ switchRoleStateListener: ISwitchRoleStateListener?
+ ) {
+ apiReporter.reportFuncEvent("switchSingerRole", mapOf("newRole" to newRole), mapOf())
+ ktvApiLog("switchSingerRole oldRole: $singerRole, newRole: $newRole")
+ val oldRole = singerRole
+ if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.LeadSinger) {
+ // 1、Audience -》LeadSinger
+ // 离开观众频道
+ mRtcEngine.leaveChannelEx(
+ RtcConnection(
+ giantChorusApiConfig.audienceChannelName,
+ giantChorusApiConfig.localUid
+ )
+ )
+ joinChorus(newRole)
+ singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.CoSinger) {
+ // 2、Audience -》CoSinger
+ // 离开观众频道
+ mRtcEngine.leaveChannelEx(
+ RtcConnection(
+ giantChorusApiConfig.audienceChannelName,
+ giantChorusApiConfig.localUid
+ )
+ )
+ joinChorus(newRole)
+ singerRole = newRole
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ } else if (this.singerRole == KTVSingRole.CoSinger && newRole == KTVSingRole.Audience) {
+ // 3、CoSinger -》Audience
+ leaveChorus2(singerRole)
+ // 加入观众频道
+ mRtcEngine.joinChannelEx(
+ giantChorusApiConfig.audienceChannelToken,
+ RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid),
+ ChannelMediaOptions(),
+ object : IRtcEngineEventHandler() {
+ override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
+ super.onJoinChannelSuccess(channel, uid, elapsed)
+ }
+
+ override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ super.onStreamMessage(uid, streamId, data)
+ dealWithStreamMessage(uid, streamId, data)
+ }
+
+ override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) {
+ super.onAudioMetadataReceived(uid, data)
+ dealWithAudioMetadata(uid, data)
+ }
+ })
+ mRtcEngine.setParametersEx(
+ "{\"rtc.use_audio4\": true}",
+ RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)
+ )
+
+ singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else if (this.singerRole == KTVSingRole.LeadSinger && newRole == KTVSingRole.Audience) {
+ // 4、LeadSinger -》Audience
+ stopSing()
+ leaveChorus2(singerRole)
+
+ // 加入观众频道
+ mRtcEngine.joinChannelEx(
+ giantChorusApiConfig.audienceChannelToken,
+ RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid),
+ ChannelMediaOptions(),
+ object : IRtcEngineEventHandler() {
+ override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
+ super.onJoinChannelSuccess(channel, uid, elapsed)
+ }
+
+ override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ super.onStreamMessage(uid, streamId, data)
+ dealWithStreamMessage(uid, streamId, data)
+ }
+
+ override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) {
+ super.onAudioMetadataReceived(uid, data)
+ dealWithAudioMetadata(uid, data)
+ }
+ })
+ mRtcEngine.setParametersEx(
+ "{\"rtc.use_audio4\": true}",
+ RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)
+ )
+
+ singerRole = newRole
+ ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) }
+ switchRoleStateListener?.onSwitchRoleSuccess()
+ } else {
+ switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.NO_PERMISSION)
+ ktvApiLogError("Error!You can not switch role from $singerRole to $newRole!")
+ }
+ }
+
+ override fun loadMusic(
+ songCode: Long,
+ config: KTVLoadMusicConfiguration,
+ musicLoadStateListener: IMusicLoadStateListener
+ ) {
+ apiReporter.reportFuncEvent("loadMusic", mapOf("songCode" to songCode, "config" to config), mapOf())
+ ktvApiLog("loadMusic called: songCode $songCode")
+
+ val internalSongCode = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ ktvApiLog("loadMusic internalSongCode $internalSongCode")
+ // 设置到全局, 连续调用以最新的为准
+ this.songCode = internalSongCode
+
+ this.songIdentifier = config.songIdentifier
+ this.mainSingerUid = config.mainSingerUid
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+
+ if (config.mode == KTVLoadMusicMode.LOAD_NONE) {
+ return
+ }
+
+ if (config.mode == KTVLoadMusicMode.LOAD_LRC_ONLY) {
+ // 只加载歌词
+ loadLyricAndPitch(config.needPitch, internalSongCode) { song, lyricUrl, pitchUrl ->
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@loadLyricAndPitch
+ }
+
+ if (lyricUrl == null) {
+ // 加载歌词失败
+ ktvApiLogError("loadMusic failed: NO_LYRIC_URL")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.NO_LYRIC_URL)
+ } else {
+ // 加载歌词成功
+ ktvApiLog("loadMusic success")
+ lrcView?.onDownloadLrcData(lyricUrl, pitchUrl)
+ musicLoadStateListener.onMusicLoadSuccess(songCode, lyricUrl)
+ }
+ }
+ return
+ }
+
+ // 预加载歌曲
+ preLoadMusic(internalSongCode) { song, percent, status, msg, lrcUrl ->
+ if (status == MccExState.PRELOAD_STATE_COMPLETED) {
+ // 预加载歌曲成功
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@preLoadMusic
+ }
+ if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_AND_LRC) {
+ // 需要加载歌词
+ loadLyricAndPitch(config.needPitch, song) { _, lyricUrl, pitchUrl ->
+ if (this.songCode != song) {
+ // 当前歌曲已发生变化,以最新load歌曲为准
+ ktvApiLogError("loadMusic failed: CANCELED")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.CANCELED)
+ return@loadLyricAndPitch
+ }
+
+ if (lyricUrl == null) {
+ // 加载歌词失败
+ ktvApiLogError("loadMusic failed: NO_LYRIC_URL")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.NO_LYRIC_URL)
+ } else {
+ // 加载歌词成功
+ ktvApiLog("loadMusic success")
+ lrcView?.onDownloadLrcData(lyricUrl, pitchUrl)
+ musicLoadStateListener.onMusicLoadProgress(
+ songCode,
+ 100,
+ MusicLoadStatus.COMPLETED,
+ msg,
+ lrcUrl
+ )
+ musicLoadStateListener.onMusicLoadSuccess(songCode, lyricUrl)
+ }
+ }
+ } else if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_ONLY) {
+ // 不需要加载歌词
+ ktvApiLog("loadMusic success")
+ musicLoadStateListener.onMusicLoadProgress(songCode, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl)
+ musicLoadStateListener.onMusicLoadSuccess(songCode, "")
+ }
+ } else if (status == MccExState.PRELOAD_STATE_PRELOADING) {
+ // 预加载歌曲加载中
+ musicLoadStateListener.onMusicLoadProgress(
+ songCode,
+ percent,
+ MusicLoadStatus.INPROGRESS,
+ msg,
+ lrcUrl
+ )
+ } else {
+ // 预加载歌曲失败
+ ktvApiLogError("loadMusic failed: MUSIC_PRELOAD_FAIL")
+ musicLoadStateListener.onMusicLoadFail(songCode, KTVLoadMusicFailReason.MUSIC_PRELOAD_FAIL)
+ }
+ }
+ }
+
+ override fun removeMusic(songCode: Long) {
+ apiReporter.reportFuncEvent("removeMusic", mapOf("songCode" to songCode), mapOf())
+// val ret = mMusicCenter.removeCache(songCode)
+// if (ret < 0) {
+// ktvApiLogError("removeMusic failed, ret: $ret")
+// }
+ }
+
+ override fun startScore(
+ songCode: Long,
+ onStartScoreCallback: (songCode: Long, status: MccExState, msg: MccExStateReason) -> Unit
+ ) {
+ val code = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ val ret = mMusicCenter.startScore(code)
+ if (ret < 0) {
+ onStartScoreCallback.invoke(
+ songCode,
+ MccExState.START_SCORE_STATE_FAIL,
+ MccExStateReason.STATE_REASON_ERROR
+ )
+ } else {
+ startScoreMap[code.toString()] = onStartScoreCallback
+ }
+ }
+
+ override fun startSing(songCode: Long, startPos: Long) {
+ apiReporter.reportFuncEvent("startSing", mapOf("songCode" to songCode, "startPos" to startPos), mapOf())
+ ktvApiLog("playSong called: $singerRole")
+
+ if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) {
+ ktvApiLogError("startSing failed: error singerRole")
+ return
+ }
+
+ val internalSongCode = mMusicCenter.getInternalSongCode(songCode.toString(), null)
+ if (this.songCode != internalSongCode) {
+ ktvApiLogError("startSing failed: canceled")
+ return
+ }
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+
+ // 导唱
+ mPlayer.setPlayerOption("enable_multi_audio_track", 1)
+ val ret = mPlayer.open(internalSongCode, startPos)
+ if (ret != 0) {
+ ktvApiLogError("mpk open failed: $ret")
+ }
+ }
+
+ override fun resumeSing() {
+ apiReporter.reportFuncEvent("resumeSing", mapOf(), mapOf())
+ ktvApiLog("resumePlay called")
+ mPlayer.resume()
+ }
+
+ override fun pauseSing() {
+ apiReporter.reportFuncEvent("pauseSing", mapOf(), mapOf())
+ ktvApiLog("pausePlay called")
+ mPlayer.pause()
+ }
+
+ override fun seekSing(time: Long) {
+ apiReporter.reportFuncEvent("seekSing", mapOf("time" to time), mapOf())
+ ktvApiLog("seek called")
+ mPlayer.seek(time)
+ syncPlayProgress(time)
+ }
+
+ override fun setLrcView(view: ILrcView) {
+ apiReporter.reportFuncEvent("setLrcView", mapOf(), mapOf())
+ ktvApiLog("setLrcView called")
+ this.lrcView = view
+ }
+
+ override fun muteMic(mute: Boolean) {
+ apiReporter.reportFuncEvent("muteMic", mapOf("mute" to mute), mapOf())
+ this.isOnMicOpen = !mute
+ if (singerRole == KTVSingRole.Audience) return
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.publishMicrophoneTrack = isOnMicOpen
+ channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER
+ mRtcEngine.updateChannelMediaOptions(channelMediaOption)
+ mRtcEngine.muteLocalAudioStreamEx(!isOnMicOpen, singChannelRtcConnection)
+ }
+
+ override fun setAudioPlayoutDelay(audioPlayoutDelay: Int) {
+ apiReporter.reportFuncEvent("setAudioPlayoutDelay", mapOf("audioPlayoutDelay" to audioPlayoutDelay), mapOf())
+ this.audioPlayoutDelay = audioPlayoutDelay
+ }
+
+ fun setSingingScore(score: Int) {
+ this.singingScore = score
+ }
+
+ fun setAudienceStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ dealWithStreamMessage(uid, streamId, data)
+ }
+
+ fun setAudienceAudioMetadataReceived(uid: Int, data: ByteArray?) {
+ dealWithAudioMetadata(uid, data)
+ }
+
+ override fun getMediaPlayer(): IMediaPlayer {
+ return mPlayer
+ }
+
+ override fun switchAudioTrack(mode: AudioTrackMode) {
+ apiReporter.reportFuncEvent("switchAudioTrack", mapOf("mode" to mode), mapOf())
+ when (singerRole) {
+ KTVSingRole.LeadSinger, KTVSingRole.SoloSinger -> {
+ when (mode) {
+ AudioTrackMode.YUAN_CHANG -> mPlayer.selectMultiAudioTrack(0, 0)
+ AudioTrackMode.BAN_ZOU -> mPlayer.selectMultiAudioTrack(1, 1)
+ AudioTrackMode.DAO_CHANG -> mPlayer.selectMultiAudioTrack(0, 1)
+ }
+ }
+
+ KTVSingRole.CoSinger -> {
+ when (mode) {
+ AudioTrackMode.YUAN_CHANG -> mPlayer.selectAudioTrack(0)
+ AudioTrackMode.BAN_ZOU -> mPlayer.selectAudioTrack(1)
+ AudioTrackMode.DAO_CHANG -> ktvApiLogError("CoSinger can not switch to DAO_CHANG")
+ }
+ }
+
+ KTVSingRole.Audience -> ktvApiLogError("CoSinger can not switch audio track")
+ }
+ }
+
+ // ------------------ inner KTVApi --------------------
+ private fun stopSing() {
+ ktvApiLog("stopSong called")
+
+ val channelMediaOption = ChannelMediaOptions()
+ channelMediaOption.autoSubscribeAudio = true
+ channelMediaOption.publishMediaPlayerAudioTrack = false
+ mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption, singChannelRtcConnection)
+
+ mPlayer.stop()
+
+ // 更新音频配置
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ }
+
+ private val subScribeSingerMap = mutableMapOf() //
+ private val singerList = mutableListOf() //
+ private var mainSingerDelay = 0
+ private fun joinChorus(newRole: KTVSingRole) {
+ ktvApiLog("joinChorus: $newRole")
+ val singChannelMediaOptions = ChannelMediaOptions()
+ singChannelMediaOptions.autoSubscribeAudio = true
+ singChannelMediaOptions.publishMicrophoneTrack = true
+ singChannelMediaOptions.clientRoleType = CLIENT_ROLE_BROADCASTER
+ singChannelMediaOptions.isAudioFilterable = newRole != KTVSingRole.LeadSinger // 主唱不参加TopN
+
+ // 加入演唱频道
+ mRtcEngine.joinChannelEx(
+ giantChorusApiConfig.chorusChannelToken,
+ singChannelRtcConnection,
+ singChannelMediaOptions,
+ object :
+ IRtcEngineEventHandler() {
+ override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
+ super.onJoinChannelSuccess(channel, uid, elapsed)
+ ktvApiLog("singChannel onJoinChannelSuccess: $newRole")
+ }
+
+ override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ super.onStreamMessage(uid, streamId, data)
+ dealWithStreamMessage(uid, streamId, data)
+ }
+
+ override fun onAudioVolumeIndication(speakers: Array?, totalVolume: Int) {
+ val allSpeakers = speakers ?: return
+ // TODO: 2024/10/17 音高 maccEx 回调
+ // VideoPitch 回调, 用于同步各端音准
+// if (singerRole != KTVSingRole.Audience) {
+// for (info in allSpeakers) {
+// if (info.uid == 0) {
+// pitch =
+// if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && isOnMicOpen) {
+// info.voicePitch
+// } else {
+// 0.0
+// }
+// }
+// }
+// }
+ }
+
+ // 用于合唱校准
+ override fun onLocalAudioStats(stats: LocalAudioStats?) {
+ if (KTVApi.useCustomAudioSource) return
+ val audioState = stats ?: return
+ audioPlayoutDelay = audioState.audioPlayoutDelay
+ }
+
+ // 用于检测耳机状态
+ override fun onAudioRouteChanged(routing: Int) { // 0\2\5 earPhone
+ audioRouting = routing
+ processAudioProfessionalProfile()
+ }
+
+ // 用于检测收发流状态
+ override fun onAudioPublishStateChanged(
+ channel: String?,
+ oldState: Int,
+ newState: Int,
+ elapseSinceLastState: Int
+ ) {
+ ktvApiLog("onAudioPublishStateChanged: oldState: $oldState, newState: $newState")
+ if (newState == 3) {
+ isPublishAudio = true
+ processAudioProfessionalProfile()
+ } else if (newState == 1) {
+ isPublishAudio = false
+ }
+ }
+
+ // 延迟选路策略
+ override fun onUserJoined(uid: Int, elapsed: Int) {
+ super.onUserJoined(uid, elapsed)
+ if (uid != giantChorusApiConfig.musicStreamUid && subScribeSingerMap.size < 8) {
+ mRtcEngine.muteRemoteAudioStreamEx(uid, false, singChannelRtcConnection)
+ if (uid != mainSingerUid) {
+ subScribeSingerMap[uid] = 0
+ }
+ } else if (uid != giantChorusApiConfig.musicStreamUid && subScribeSingerMap.size == 8) {
+ mRtcEngine.muteRemoteAudioStreamEx(uid, true, singChannelRtcConnection)
+ }
+ if (uid != giantChorusApiConfig.musicStreamUid && uid != mainSingerUid) {
+ singerList.add(uid)
+ }
+ }
+
+ override fun onUserOffline(uid: Int, reason: Int) {
+ super.onUserOffline(uid, reason)
+ subScribeSingerMap.remove(uid)
+ singerList.remove(uid)
+ }
+
+ override fun onLeaveChannel(stats: RtcStats?) {
+ super.onLeaveChannel(stats)
+ subScribeSingerMap.clear()
+ singerList.clear()
+ }
+
+ override fun onRemoteAudioStats(stats: RemoteAudioStats?) {
+ super.onRemoteAudioStats(stats)
+ stats ?: return
+ if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.RANDOM || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N) return
+ val uid = stats.uid
+ if (uid == mainSingerUid) {
+ mainSingerDelay = stats.e2eDelay
+ }
+// if (uid == mainSingerUid && stats.e2eDelay > 300) {
+// //ToastUtils.showToast("主唱 $mainSingerUid 延迟超过300ms,目前延迟:${stats.ntpE2eDelay}")
+// }
+// if (subScribeSingerMap.any { it.key == uid } && stats.e2eDelay > 300) {
+// //ToastUtils.showToast("当前订阅用户 $uid 延迟超过300ms,目前延迟:${stats.ntpE2eDelay}")
+// }
+ if (uid != mainSingerUid && uid != giantChorusApiConfig.musicStreamUid
+ && subScribeSingerMap.containsKey(uid)
+ ) {
+ subScribeSingerMap[uid] = stats.e2eDelay
+ }
+ }
+ })
+
+ mRtcEngine.setParametersEx("{\"che.audio.max_mixed_participants\": 8}", singChannelRtcConnection)
+ mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", singChannelRtcConnection)
+
+ // 选路策略处理
+ if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.BY_DELAY_AND_TOP_N) {
+ if (newRole == KTVSingRole.LeadSinger) {
+ mRtcEngine.setParametersEx(
+ "{\"che.audio.filter_streams\":${KTVApi.routeSelectionConfig.streamNum}}",
+ singChannelRtcConnection
+ )
+ } else {
+ mRtcEngine.setParametersEx(
+ "{\"che.audio.filter_streams\":${KTVApi.routeSelectionConfig.streamNum - 1}}",
+ singChannelRtcConnection
+ )
+ }
+ } else {
+ mRtcEngine.setParametersEx("{\"che.audio.filter_streams\": 0}", singChannelRtcConnection)
+ }
+ mRtcEngine.enableAudioVolumeIndicationEx(50, 10, true, singChannelRtcConnection)
+
+ when (newRole) {
+ KTVSingRole.LeadSinger -> {
+ // 更新音频配置
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}")
+
+ // mpk流加入频道
+ val options = ChannelMediaOptions()
+ options.autoSubscribeAudio = false
+ options.autoSubscribeVideo = false
+ options.publishMicrophoneTrack = false
+ options.publishMediaPlayerAudioTrack = true
+ options.publishMediaPlayerId = mPlayer.mediaPlayerId
+ options.clientRoleType = CLIENT_ROLE_BROADCASTER
+ // 防止主唱和合唱听见mpk流的声音
+ options.enableAudioRecordingOrPlayout = false
+
+ val rtcConnection = RtcConnection()
+ rtcConnection.channelId = giantChorusApiConfig.chorusChannelName
+ rtcConnection.localUid = giantChorusApiConfig.musicStreamUid
+ mpkConnection = rtcConnection
+
+ mRtcEngine.joinChannelEx(
+ giantChorusApiConfig.musicStreamToken,
+ mpkConnection,
+ options,
+ object : IRtcEngineEventHandler() {
+ override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) {
+ ktvApiLog("onMPKJoinChannelSuccess, channel: $channel, uid: $uid")
+ }
+
+ override fun onLeaveChannel(stats: RtcStats) {
+ ktvApiLog("onMPKLeaveChannel")
+ }
+ })
+ mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", mpkConnection)
+ }
+
+ KTVSingRole.CoSinger -> {
+ // 防止主唱和合唱听见mpk流的声音
+ mRtcEngine.muteRemoteAudioStreamEx(
+ giantChorusApiConfig.musicStreamUid,
+ true,
+ singChannelRtcConnection
+ )
+
+ // 更新音频配置
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+
+ // 预加载歌曲成功
+ // 导唱
+ mPlayer.setPlayerOption("enable_multi_audio_track", 1)
+ if (giantChorusApiConfig.musicType == KTVMusicType.SONG_CODE) {
+ val ret = mPlayer.open(songCode, 0) // TODO open failed
+ if (ret != 0) {
+ ktvApiLogError("mpk open failed: $ret")
+ }
+ }
+ }
+
+ else -> {
+ ktvApiLogError("JoinChorus with Wrong role: $singerRole")
+ }
+ }
+
+ mRtcEngine.muteRemoteAudioStreamEx(giantChorusApiConfig.musicStreamUid, true, singChannelRtcConnection)
+ // 加入演唱频道后,创建data stream
+ renewInnerDataStreamId()
+ }
+
+ private fun leaveChorus2(role: KTVSingRole) {
+ ktvApiLog("leaveChorus: $singerRole")
+ when (role) {
+ KTVSingRole.LeadSinger -> {
+ mRtcEngine.leaveChannelEx(mpkConnection)
+ }
+
+ KTVSingRole.CoSinger -> {
+ mPlayer.stop()
+
+ // 更新音频配置
+ mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING)
+ mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}")
+ mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}")
+ mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}")
+ }
+
+ else -> {
+ ktvApiLogError("JoinChorus with wrong role: $singerRole")
+ }
+ }
+ mRtcEngine.leaveChannelEx(singChannelRtcConnection)
+ }
+
+ // ------------------ inner --------------------
+
+ private fun isChorusCoSinger(): Boolean {
+ return singerRole == KTVSingRole.CoSinger
+ }
+
+ private fun sendStreamMessageWithJsonObject(
+ obj: JSONObject,
+ success: (isSendSuccess: Boolean) -> Unit
+ ) {
+ val ret =
+ mRtcEngine.sendStreamMessageEx(innerDataStreamId, obj.toString().toByteArray(), singChannelRtcConnection)
+ if (ret == 0) {
+ success.invoke(true)
+ } else {
+ ktvApiLogError("sendStreamMessageWithJsonObject failed: $ret, innerDataStreamId:$innerDataStreamId")
+ }
+ }
+
+ private fun syncPlayState(
+ state: Constants.MediaPlayerState,
+ error: Constants.MediaPlayerReason
+ ) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "PlayerState"
+ msg["state"] = Constants.MediaPlayerState.getValue(state)
+ msg["error"] = Constants.MediaPlayerReason.getValue(error)
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ private fun syncPlayProgress(time: Long) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "Seek"
+ msg["position"] = time
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ // ------------------ 歌词播放、同步 ------------------
+ private fun startDisplayLrc() {
+ ktvApiLog("startDisplayLrc called")
+ mStopDisplayLrc = false
+ displayLrcFuture = scheduledThreadPool.scheduleAtFixedRate(displayLrcTask, 0, 20, TimeUnit.MILLISECONDS)
+ }
+
+ // 停止播放歌词
+ private fun stopDisplayLrc() {
+ ktvApiLog("stopDisplayLrc called")
+ mStopDisplayLrc = true
+ displayLrcFuture?.cancel(true)
+ displayLrcFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(displayLrcTask)
+ }
+ }
+
+ // ------------------ 评分驱动混音同步 ------------------
+ private fun sendSyncScore() {
+ val jsonObject = JSONObject()
+ jsonObject.put("service", "audio_smart_mixer") // data message的目标消费者(服务)名
+ jsonObject.put("version", "V1") //协议版本号(而非服务版本号)
+ val payloadJson = JSONObject()
+ payloadJson.put("cname", giantChorusApiConfig.chorusChannelName) // 频道名,演唱频道
+ payloadJson.put("uid", giantChorusApiConfig.localUid.toString()) // 自己的uid
+ payloadJson.put("uLv", -1) //user-leve1(用户级别,若无则为 -1,Level 越高,越重要)
+ payloadJson.put("specialLabel", 0) //0: default-mode ,1:这个用户需要被排除出智能混音
+ payloadJson.put("audioRoute", audioRouting) //音频路由:监听 onAudioRouteChanged
+ payloadJson.put("vocalScore", singingScore) //单句打分
+ jsonObject.put("payload", payloadJson)
+ ktvApiLog("sendSyncScore: $jsonObject")
+ sendStreamMessageWithJsonObject(jsonObject) {}
+ }
+
+ // 开始发送分数 3s/次
+ private fun startSyncScore() {
+ mStopSyncScore = false
+ mSyncScoreFuture = scheduledThreadPool.scheduleAtFixedRate(mSyncScoreTask, 0, 3000, TimeUnit.MILLISECONDS)
+ }
+
+ // 停止发送分数
+ private fun stopSyncScore() {
+ mStopSyncScore = true
+ singingScore = 0
+
+ mSyncScoreFuture?.cancel(true)
+ mSyncScoreFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(mSyncScoreTask)
+ }
+ }
+
+ // ------------------ 云端合流信息同步 ------------------
+ private fun sendSyncCloudConvergenceStatus() {
+ val jsonObject = JSONObject()
+ jsonObject.put("service", "audio_smart_mixer_status") // data message的目标消费者(服务)名
+ jsonObject.put("version", "V1") //协议版本号(而非服务版本号)
+ val payloadJson = JSONObject()
+ payloadJson.put("Ts", getNtpTimeInMs()) // NTP 时间
+ payloadJson.put("cname", giantChorusApiConfig.chorusChannelName) // 频道名
+ payloadJson.put("status", getCloudConvergenceStatus()) //(-1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态)
+ payloadJson.put("bgmUID", mpkConnection?.localUid.toString()) // mpk流的uid
+ payloadJson.put("leadsingerUID", mainSingerUid.toString()) //("-1" = unknown) //主唱Uid
+ jsonObject.put("payload", payloadJson)
+ ktvApiLog("sendSyncCloudConvergenceStatus: $jsonObject")
+ sendStreamMessageWithJsonObject(jsonObject) {}
+ }
+
+ // -1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态)
+ private fun getCloudConvergenceStatus(): Int {
+ var status = -1
+ when (this.mediaPlayerState) {
+ MediaPlayerState.PLAYER_STATE_PLAYING -> status = 1
+ MediaPlayerState.PLAYER_STATE_PAUSED -> status = 2
+ else -> {}
+ }
+ return status
+ }
+
+ // 开始发送分数 200ms/次
+ private fun startSyncCloudConvergenceStatus() {
+ mStopSyncCloudConvergenceStatus = false
+ mSyncCloudConvergenceStatusFuture =
+ scheduledThreadPool.scheduleAtFixedRate(mSyncCloudConvergenceStatusTask, 0, 200, TimeUnit.MILLISECONDS)
+ }
+
+ // 停止发送分数
+ private fun stopSyncCloudConvergenceStatus() {
+ mStopSyncCloudConvergenceStatus = true
+
+ mSyncCloudConvergenceStatusFuture?.cancel(true)
+ mSyncCloudConvergenceStatusFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(mSyncCloudConvergenceStatusTask)
+ }
+ }
+
+ // ------------------ 延迟选路 ------------------
+ private var mStopProcessDelay = true
+
+ private val mProcessDelayTask = Runnable {
+ if (!mStopProcessDelay && singerRole != KTVSingRole.Audience) {
+ val n =
+ if (singerRole == KTVSingRole.LeadSinger) KTVApi.routeSelectionConfig.streamNum else KTVApi.routeSelectionConfig.streamNum - 1
+ val sortedEntries = subScribeSingerMap.entries.sortedBy { it.value }
+ val other = sortedEntries.drop(3)
+ val drop = mutableListOf()
+ if (n > 3) {
+ other.drop(n - 3).forEach { (uid, _) ->
+ drop.add(uid)
+ mRtcEngine.muteRemoteAudioStreamEx(uid, true, singChannelRtcConnection)
+ subScribeSingerMap.remove(uid)
+ }
+ }
+ ktvApiLog("选路重新订阅, drop:$drop")
+
+ val filteredList = singerList.filter { !subScribeSingerMap.containsKey(it) }
+ val filteredList2 = filteredList.filter { !drop.contains(it) }
+ val shuffledList = filteredList2.shuffled()
+ if (subScribeSingerMap.size < 8) {
+ val randomSingers = shuffledList.take(8 - subScribeSingerMap.size)
+ ktvApiLog("选路重新订阅, newSingers:$randomSingers")
+ for (singer in randomSingers) {
+ subScribeSingerMap[singer] = 0
+ mRtcEngine.muteRemoteAudioStreamEx(singer, false, singChannelRtcConnection)
+ }
+ }
+ ktvApiLog("选路重新订阅, newSubScribeSingerMap:$subScribeSingerMap")
+ }
+ }
+
+ private val mProcessSubscribeTask = Runnable {
+ if (!mStopProcessDelay && singerRole != KTVSingRole.Audience) {
+ val n =
+ if (singerRole == KTVSingRole.LeadSinger) KTVApi.routeSelectionConfig.streamNum else KTVApi.routeSelectionConfig.streamNum - 1
+ val sortedEntries = subScribeSingerMap.entries.sortedBy { it.value }
+ val mustToHave = sortedEntries.take(3)
+ mustToHave.forEach { (uid, _) ->
+ mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 100, singChannelRtcConnection)
+ }
+ val other = sortedEntries.drop(3)
+ if (n > 3) {
+ other.take(n - 3).forEach { (uid, delay) ->
+ if (delay > 300) {
+ mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 0, singChannelRtcConnection)
+ } else {
+ mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 100, singChannelRtcConnection)
+ }
+ }
+ other.drop(n - 3).forEach { (uid, _) ->
+ mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 0, singChannelRtcConnection)
+ }
+ }
+
+ ktvApiLog("选路排序+调整播放音量, mustToHave:$mustToHave, other:$other")
+ }
+ }
+
+ private var mProcessDelayFuture: ScheduledFuture<*>? = null
+ private var mProcessSubscribeFuture: ScheduledFuture<*>? = null
+ private fun startProcessDelay() {
+ if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.RANDOM) return
+ mStopProcessDelay = false
+ mProcessDelayFuture =
+ scheduledThreadPool.scheduleAtFixedRate(mProcessDelayTask, 10000, 20000, TimeUnit.MILLISECONDS)
+ mProcessSubscribeFuture =
+ scheduledThreadPool.scheduleAtFixedRate(mProcessSubscribeTask, 15000, 20000, TimeUnit.MILLISECONDS)
+ }
+
+ private fun stopProcessDelay() {
+ mStopProcessDelay = true
+
+ mProcessDelayFuture?.cancel(true)
+ mProcessSubscribeFuture?.cancel(true)
+ mProcessDelayFuture = null
+ if (scheduledThreadPool is ScheduledThreadPoolExecutor) {
+ scheduledThreadPool.remove(mProcessDelayTask)
+ scheduledThreadPool.remove(mProcessSubscribeTask)
+ }
+ }
+
+ /**
+ * Load lyric and pitch
+ *
+ * @param needPitch
+ * @param songNo
+ * @param onLoadCallback
+ * @receiver
+ */
+ private fun loadLyricAndPitch(
+ needPitch: Boolean,
+ songNo: Long,
+ onLoadCallback: (songNo: Long, lyricUrl: String?, pitchUrl: String?) -> Unit
+ ) {
+ if (needPitch) {
+ loadLyric(songNo) { song, lyric ->
+ loadPitch(songNo) { _, pitch ->
+ onLoadCallback.invoke(song, lyric, pitch)
+ }
+ }
+ } else {
+ loadLyric(songNo) { song, lyric ->
+ onLoadCallback.invoke(song, lyric, null)
+ }
+ }
+ }
+
+ private fun loadLyric(songNo: Long, onLoadLyricCallback: (songNo: Long, lyricUrl: String?) -> Unit) {
+ ktvApiLog("loadLyric: $songNo")
+ val requestId = mMusicCenter.getLyric(songNo, LyricType.KRC)
+ if (requestId.isEmpty()) {
+ onLoadLyricCallback.invoke(songNo, null)
+ return
+ }
+ lyricSongCodeMap[requestId] = songNo
+ lyricCallbackMap[requestId] = onLoadLyricCallback
+ }
+
+ private fun loadPitch(
+ songNo: Long,
+ onLoadPitchCallback: (songNo: Long, pitchUrl: String?) -> Unit
+ ) {
+ ktvApiLog("loadPitch: $songNo")
+ val requestId = mMusicCenter.getPitch(songNo)
+ if (requestId.isEmpty()) {
+ onLoadPitchCallback.invoke(songNo, null)
+ return
+ }
+ pitchCallbackMap[requestId] = onLoadPitchCallback
+ }
+
+ private fun preLoadMusic(
+ songNo: Long, onLoadMusicCallback: (
+ songCode: Long,
+ percent: Int,
+ status: MccExState,
+ msg: String?,
+ lyricUrl: String?
+ ) -> Unit
+ ) {
+ ktvApiLog("loadMusic: $songNo")
+ val ret = mMusicCenter.isPreloaded(songNo)
+ if (ret == 0) {
+ loadMusicCallbackMap.remove(songNo.toString())
+ onLoadMusicCallback(songNo, 100, MccExState.PRELOAD_STATE_COMPLETED, null, null)
+ return
+ }
+
+ val retPreload = mMusicCenter.preload(songNo)
+ if (retPreload == "") {
+ ktvApiLogError("preLoadMusic failed: $retPreload")
+ loadMusicCallbackMap.remove(songNo.toString())
+ onLoadMusicCallback(songNo, 100, MccExState.PRELOAD_STATE_FAILED, null, null)
+ return
+ }
+ loadMusicCallbackMap[songNo.toString()] = onLoadMusicCallback
+ }
+
+ private fun getNtpTimeInMs(): Long {
+ val currentNtpTime = mRtcEngine.ntpWallTimeInMs
+ return if (currentNtpTime != 0L) {
+ currentNtpTime + 2208988800L * 1000
+ } else {
+ ktvApiLogError("getNtpTimeInMs DeviceDelay is zero!!!")
+ System.currentTimeMillis()
+ }
+ }
+
+ private fun runOnMainThread(r: Runnable) {
+ if (Thread.currentThread() == mainHandler.looper.thread) {
+ r.run()
+ } else {
+ mainHandler.post(r)
+ }
+ }
+
+ // ------------------------ AgoraRtcEvent ------------------------
+ private fun dealWithStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
+ val jsonMsg: JSONObject
+ val messageData = data ?: return
+ try {
+ val strMsg = String(messageData)
+ jsonMsg = JSONObject(strMsg)
+ if (!jsonMsg.has("cmd")) return
+ if (jsonMsg.getString("cmd") == "setLrcTime") { //同步歌词
+ val position = jsonMsg.getLong("time")
+ val realPosition = jsonMsg.getLong("realTime")
+ val duration = jsonMsg.getLong("duration")
+ val remoteNtp = jsonMsg.getLong("ntp")
+ val songId = jsonMsg.getString("songIdentifier")
+ val mpkState = jsonMsg.getInt("playerState")
+
+ if (isChorusCoSinger()) {
+ // 本地BGM校准逻辑
+ if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED) {
+ // 合唱者开始播放音乐前调小远端人声
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+ // 收到leadSinger第一次播放位置消息时开启本地播放(先通过seek校准)
+ val delta = getNtpTimeInMs() - remoteNtp
+ val expectPosition = position + delta + audioPlayoutDelay
+ if (expectPosition in 1 until duration) {
+ mPlayer.seek(expectPosition)
+ }
+ mPlayer.play()
+ } else if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING) {
+ val localNtpTime = getNtpTimeInMs()
+ val localPosition =
+ localNtpTime - this.localPlayerSystemTime + this.localPlayerPosition // 当前副唱的播放时间
+ val expectPosition =
+ localNtpTime - remoteNtp + position + audioPlayoutDelay // 实际主唱的播放时间
+ val diff = expectPosition - localPosition
+ if (KTVApi.debugMode) {
+ ktvApiLog(
+ "play_status_seek: " + diff + " audioPlayoutDelay:" + audioPlayoutDelay + " localNtpTime: " + localNtpTime + " expectPosition: " + expectPosition +
+ " localPosition: " + localPosition + " ntp diff: " + (localNtpTime - remoteNtp)
+ )
+ }
+ if ((diff > 50 || diff < -50) && expectPosition < duration) { //设置阈值为50ms,避免频繁seek
+ mPlayer.seek(expectPosition)
+ }
+ } else {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = realPosition
+ }
+
+ if (MediaPlayerState.getStateByValue(mpkState) != this.mediaPlayerState) {
+ when (MediaPlayerState.getStateByValue(mpkState)) {
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mPlayer.pause()
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mPlayer.resume()
+ }
+
+ else -> {}
+ }
+ }
+ } else {
+ // 独唱观众
+ if (jsonMsg.has("ver")) {
+ recvFromDataStream = false
+ } else {
+ recvFromDataStream = true
+ if (this.songIdentifier == songId) {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = realPosition
+ } else {
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+ }
+ }
+ }
+ } else if (jsonMsg.getString("cmd") == "Seek") {
+ // 伴唱收到原唱seek指令
+ if (isChorusCoSinger()) {
+ val position = jsonMsg.getLong("position")
+ mPlayer.seek(position)
+ }
+ } else if (jsonMsg.getString("cmd") == "PlayerState") {
+ // 其他端收到原唱seek指令
+ val state = jsonMsg.getInt("state")
+ val error = jsonMsg.getInt("error")
+ ktvApiLog("onStreamMessage PlayerState: $state")
+ if (isChorusCoSinger()) {
+ when (MediaPlayerState.getStateByValue(state)) {
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mPlayer.pause()
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mPlayer.resume()
+ }
+
+ else -> {}
+ }
+ } else if (this.singerRole == KTVSingRole.Audience) {
+ this.mediaPlayerState = MediaPlayerState.getStateByValue(state)
+ }
+ ktvApiEventHandlerList.forEach {
+ it.onMusicPlayerStateChanged(
+ MediaPlayerState.getStateByValue(state),
+ Constants.MediaPlayerReason.getErrorByValue(error),
+ false
+ )
+ }
+ } else if (jsonMsg.getString("cmd") == "setVoicePitch") {
+ // TODO: 大合唱走不到这里,观众不需要展示主唱 pitch?
+ val pitch = jsonMsg.getDouble("pitch")
+ val progressInMs = jsonMsg.getInt("progressInMs")
+ if (this.singerRole == KTVSingRole.Audience) {
+ this.pitch = pitch
+ this.progressInMs = progressInMs
+ }
+ }
+ } catch (exp: JSONException) {
+ ktvApiLogError("onStreamMessage:$exp")
+ }
+ }
+
+ private fun dealWithAudioMetadata(uid: Int, data: ByteArray?) {
+ val messageData = data ?: return
+ val lrcTime = LrcTimeOuterClass.LrcTime.parseFrom(messageData)
+ if (lrcTime.type == LrcTimeOuterClass.MsgType.LRC_TIME) { //同步歌词
+ val realPosition = lrcTime.ts
+ val songId = lrcTime.songId
+ val curTs = if (this.songIdentifier == songId) realPosition else 0
+ runOnMainThread {
+ lrcView?.onUpdatePitch(songCode, pitch, progressInMs)
+ // (fix ENT-489)Make lyrics delay for 200ms
+ // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`,
+ // such as AEC/Player/Device buffer.
+ // We choose the estimated 200ms.
+ lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side
+ }
+ }
+ }
+
+ // ------------------------ IMusicContentCenterExEventHandler ------------------------
+ override fun onInitializeResult(state: MccExState, reason: MccExStateReason) {
+ ktvApiLog("onInitializeResult, state:$state, reason:$reason")
+ }
+
+ override fun onPreLoadEvent(
+ requestId: String,
+ songCode: Long,
+ percent: Int,
+ lyricPath: String,
+ pitchPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ lyricOffset: Int,
+ state: MccExState,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog(
+ "onPreLoadEvent, requestId:$requestId, songCode:$songCode, percent:$percent, " +
+ "lyricPath:$lyricPath, pitchPath:$pitchPath, state:$state, reason:$reason"
+ )
+
+ val callback = loadMusicCallbackMap[songCode.toString()] ?: return
+ if (state == MccExState.PRELOAD_STATE_COMPLETED || state == MccExState.PRELOAD_STATE_FAILED || state == MccExState.PRELOAD_STATE_REMOVED) {
+ loadMusicCallbackMap.remove(songCode.toString())
+ }
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ callback.invoke(songCode, percent, state, reason.reason.toString(), lyricPath)
+ }
+
+ override fun onStartScoreResult(songCode: Long, state: MccExState, reason: MccExStateReason) {
+ ktvApiLog("onStartScoreResult, songCode:$songCode, state:$state, reason:$reason")
+ val callback = startScoreMap.remove(songCode.toString())
+ callback?.invoke(songCode, state, reason)
+ }
+
+ override fun onLyricResult(
+ requestId: String,
+ songCode: Long,
+ lyricPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ lyricOffset: Int,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog("onLyricResult, requestId:$requestId, songCode:$songCode, lyricPath:$lyricPath, reason:$reason")
+ val callback = lyricCallbackMap[requestId] ?: return
+ val songCode = lyricSongCodeMap[requestId] ?: return
+ lyricCallbackMap.remove(requestId)
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ if (lyricPath.isEmpty()) {
+ callback(songCode, null)
+ return
+ }
+ callback(songCode, lyricPath)
+ }
+
+ override fun onPitchResult(
+ requestId: String,
+ songCode: Long,
+ pitchPath: String,
+ songOffsetBegin: Int,
+ songOffsetEnd: Int,
+ reason: MccExStateReason
+ ) {
+ ktvApiLog("onPitchResult, requestId:$requestId, songCode:$songCode, pitchPath:$pitchPath, reason:$reason")
+ val callback = pitchCallbackMap[requestId] ?: return
+ pitchCallbackMap.remove(requestId)
+ if (reason == MccExStateReason.STATE_REASON_YSD_ERROR_TOKEN_ERROR) {
+ // Token过期
+ ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() }
+ }
+ if (pitchPath.isEmpty()) {
+ callback(songCode, null)
+ return
+ }
+ callback(songCode, pitchPath)
+ }
+
+ // IMusicContentCenterExScoreEventHandler
+ override fun onLineScore(songCode: Long, value: LineScoreData) {
+ if (this.songCode == songCode) {
+ this.singingScore = value.linePitchScore
+ }
+ runOnMainThread {
+ lrcView?.onLineScore(songCode, value)
+ }
+ }
+
+ override fun onPitch(songCode: Long, data: RawScoreData) {
+ if (this.singerRole != KTVSingRole.Audience) {
+ this.pitch = data.speakerPitch.toDouble()
+ this.progressInMs = data.progressInMs
+ }
+ }
+
+ // ------------------------ AgoraRtcMediaPlayerDelegate ------------------------
+ private var duration: Long = 0
+ override fun onPlayerStateChanged(
+ state: Constants.MediaPlayerState?,
+ reason: Constants.MediaPlayerReason?
+ ) {
+ val mediaPlayerState = state ?: return
+ val mediaPlayerError = reason ?: return
+ ktvApiLog("onPlayerStateChanged called, state: $mediaPlayerState, error: $mediaPlayerError")
+ this.mediaPlayerState = mediaPlayerState
+ when (mediaPlayerState) {
+ MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED -> {
+ duration = mPlayer.duration
+ this.localPlayerPosition = 0
+ // 伴奏
+ mPlayer.selectMultiAudioTrack(1, 1)
+ if (this.singerRole == KTVSingRole.SoloSinger ||
+ this.singerRole == KTVSingRole.LeadSinger
+ ) {
+ mPlayer.play()
+ }
+ startProcessDelay()
+ }
+
+ MediaPlayerState.PLAYER_STATE_PLAYING -> {
+ mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume)
+ }
+
+ MediaPlayerState.PLAYER_STATE_PAUSED -> {
+ mRtcEngine.adjustPlaybackSignalVolume(100)
+ }
+
+ MediaPlayerState.PLAYER_STATE_STOPPED -> {
+ mRtcEngine.adjustPlaybackSignalVolume(100)
+ duration = 0
+ stopProcessDelay()
+ }
+
+ else -> {}
+ }
+
+ if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) {
+ syncPlayState(mediaPlayerState, mediaPlayerError)
+ }
+ ktvApiEventHandlerList.forEach { it.onMusicPlayerStateChanged(mediaPlayerState, mediaPlayerError, true) }
+ }
+
+ // 同步播放进度
+ override fun onPositionChanged(position_ms: Long, timestamp_ms: Long) {
+ localPlayerPosition = position_ms
+ localPlayerSystemTime = timestamp_ms
+
+ if ((this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) && position_ms > audioPlayoutDelay) {
+ val msg: MutableMap = HashMap()
+ msg["cmd"] = "setLrcTime"
+ msg["ntp"] = timestamp_ms
+ msg["duration"] = duration
+ msg["time"] =
+ position_ms - audioPlayoutDelay // "position-audioDeviceDelay" 是计算出当前播放的真实进度
+ msg["realTime"] = position_ms
+ msg["playerState"] = MediaPlayerState.getValue(this.mediaPlayerState)
+ msg["pitch"] = pitch
+ msg["progressInMs"] = progressInMs
+ msg["songIdentifier"] = songIdentifier
+ msg["forward"] = true
+ msg["ver"] = lyricSyncVersion
+ val jsonMsg = JSONObject(msg)
+ sendStreamMessageWithJsonObject(jsonMsg) {}
+ }
+
+ if (this.singerRole != KTVSingRole.Audience) {
+ mLastReceivedPlayPosTime = System.currentTimeMillis()
+ mReceivedPlayPosition = position_ms
+ } else {
+ mLastReceivedPlayPosTime = null
+ mReceivedPlayPosition = 0
+ }
+ }
+
+ override fun onPlayerEvent(
+ eventCode: Constants.MediaPlayerEvent?,
+ elapsedTime: Long,
+ message: String?
+ ) {
+ }
+
+ override fun onMetaData(type: Constants.MediaPlayerMetadataType?, data: ByteArray?) {}
+
+ override fun onPlayBufferUpdated(playCachedBuffer: Long) {}
+
+ override fun onPreloadEvent(src: String?, event: Constants.MediaPlayerPreloadEvent?) {}
+
+ override fun onAgoraCDNTokenWillExpire() {}
+
+ override fun onPlayerSrcInfoChanged(from: SrcInfo?, to: SrcInfo?) {}
+
+ override fun onPlayerInfoUpdated(info: PlayerUpdatedInfo?) {}
+
+ override fun onPlayerCacheStats(stats: CacheStatistics?) {}
+
+ override fun onPlayerPlaybackStats(stats: PlayerPlaybackStats?) {}
+
+ override fun onAudioVolumeIndication(volume: Int) {}
+}
\ No newline at end of file
diff --git a/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/LrcTimeOuterClass.java b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/LrcTimeOuterClass.java
new file mode 100644
index 0000000..faffa15
--- /dev/null
+++ b/KTVAPI/Android/lib_ktvapi_ex/src/main/java/io/agora/ktvapiex/LrcTimeOuterClass.java
@@ -0,0 +1,1042 @@
+package io.agora.ktvapiex;// Generated by the protocol buffer compiler. DO NOT EDIT!
+// source: LrcTime.proto
+
+public final class LrcTimeOuterClass {
+ private LrcTimeOuterClass() {}
+ public static void registerAllExtensions(
+ com.google.protobuf.ExtensionRegistryLite registry) {
+ }
+
+ public static void registerAllExtensions(
+ com.google.protobuf.ExtensionRegistry registry) {
+ registerAllExtensions(
+ (com.google.protobuf.ExtensionRegistryLite) registry);
+ }
+ /**
+ * Protobuf enum {@code MsgType}
+ */
+ public enum MsgType
+ implements com.google.protobuf.ProtocolMessageEnum {
+ /**
+ * UNKNOWN_TYPE = 0;
+ */
+ UNKNOWN_TYPE(0),
+ /**
+ * LRC_TIME = 1001;
+ */
+ LRC_TIME(1001),
+ UNRECOGNIZED(-1),
+ ;
+
+ /**
+ * UNKNOWN_TYPE = 0;
+ */
+ public static final int UNKNOWN_TYPE_VALUE = 0;
+ /**
+ * LRC_TIME = 1001;
+ */
+ public static final int LRC_TIME_VALUE = 1001;
+
+
+ public final int getNumber() {
+ if (this == UNRECOGNIZED) {
+ throw new IllegalArgumentException(
+ "Can't get the number of an unknown enum value.");
+ }
+ return value;
+ }
+
+ /**
+ * @param value The numeric wire value of the corresponding enum entry.
+ * @return The enum associated with the given numeric wire value.
+ * @deprecated Use {@link #forNumber(int)} instead.
+ */
+ @Deprecated
+ public static MsgType valueOf(int value) {
+ return forNumber(value);
+ }
+
+ /**
+ * @param value The numeric wire value of the corresponding enum entry.
+ * @return The enum associated with the given numeric wire value.
+ */
+ public static MsgType forNumber(int value) {
+ switch (value) {
+ case 0: return UNKNOWN_TYPE;
+ case 1001: return LRC_TIME;
+ default: return null;
+ }
+ }
+
+ public static com.google.protobuf.Internal.EnumLiteMap
+ internalGetValueMap() {
+ return internalValueMap;
+ }
+ private static final com.google.protobuf.Internal.EnumLiteMap<
+ MsgType> internalValueMap =
+ new com.google.protobuf.Internal.EnumLiteMap() {
+ public MsgType findValueByNumber(int number) {
+ return MsgType.forNumber(number);
+ }
+ };
+
+ public final com.google.protobuf.Descriptors.EnumValueDescriptor
+ getValueDescriptor() {
+ if (this == UNRECOGNIZED) {
+ throw new IllegalStateException(
+ "Can't get the descriptor of an unrecognized enum value.");
+ }
+ return getDescriptor().getValues().get(ordinal());
+ }
+ public final com.google.protobuf.Descriptors.EnumDescriptor
+ getDescriptorForType() {
+ return getDescriptor();
+ }
+ public static final com.google.protobuf.Descriptors.EnumDescriptor
+ getDescriptor() {
+ return LrcTimeOuterClass.getDescriptor().getEnumTypes().get(0);
+ }
+
+ private static final MsgType[] VALUES = values();
+
+ public static MsgType valueOf(
+ com.google.protobuf.Descriptors.EnumValueDescriptor desc) {
+ if (desc.getType() != getDescriptor()) {
+ throw new IllegalArgumentException(
+ "EnumValueDescriptor is not for this type.");
+ }
+ if (desc.getIndex() == -1) {
+ return UNRECOGNIZED;
+ }
+ return VALUES[desc.getIndex()];
+ }
+
+ private final int value;
+
+ private MsgType(int value) {
+ this.value = value;
+ }
+
+ // @@protoc_insertion_point(enum_scope:MsgType)
+ }
+
+ public interface LrcTimeOrBuilder extends
+ // @@protoc_insertion_point(interface_extends:LrcTime)
+ com.google.protobuf.MessageOrBuilder {
+
+ /**
+ * .MsgType type = 1;
+ * @return The enum numeric value on the wire for type.
+ */
+ int getTypeValue();
+ /**
+ * .MsgType type = 1;
+ * @return The type.
+ */
+ MsgType getType();
+
+ /**
+ * bool forward = 2;
+ * @return The forward.
+ */
+ boolean getForward();
+
+ /**
+ * int64 ts = 3;
+ * @return The ts.
+ */
+ long getTs();
+
+ /**
+ * string songId = 4;
+ * @return The songId.
+ */
+ String getSongId();
+ /**
+ * string songId = 4;
+ * @return The bytes for songId.
+ */
+ com.google.protobuf.ByteString
+ getSongIdBytes();
+
+ /**
+ * int32 uid = 5;
+ * @return The uid.
+ */
+ int getUid();
+ }
+ /**
+ * Protobuf type {@code LrcTime}
+ */
+ public static final class LrcTime extends
+ com.google.protobuf.GeneratedMessageV3 implements
+ // @@protoc_insertion_point(message_implements:LrcTime)
+ LrcTimeOrBuilder {
+ private static final long serialVersionUID = 0L;
+ // Use LrcTime.newBuilder() to construct.
+ private LrcTime(com.google.protobuf.GeneratedMessageV3.Builder> builder) {
+ super(builder);
+ }
+ private LrcTime() {
+ type_ = 0;
+ songId_ = "";
+ }
+
+ @Override
+ @SuppressWarnings({"unused"})
+ protected Object newInstance(
+ UnusedPrivateParameter unused) {
+ return new LrcTime();
+ }
+
+ @Override
+ public final com.google.protobuf.UnknownFieldSet
+ getUnknownFields() {
+ return this.unknownFields;
+ }
+ private LrcTime(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ this();
+ if (extensionRegistry == null) {
+ throw new NullPointerException();
+ }
+ com.google.protobuf.UnknownFieldSet.Builder unknownFields =
+ com.google.protobuf.UnknownFieldSet.newBuilder();
+ try {
+ boolean done = false;
+ while (!done) {
+ int tag = input.readTag();
+ switch (tag) {
+ case 0:
+ done = true;
+ break;
+ case 8: {
+ int rawValue = input.readEnum();
+
+ type_ = rawValue;
+ break;
+ }
+ case 16: {
+
+ forward_ = input.readBool();
+ break;
+ }
+ case 24: {
+
+ ts_ = input.readInt64();
+ break;
+ }
+ case 34: {
+ String s = input.readStringRequireUtf8();
+
+ songId_ = s;
+ break;
+ }
+ case 40: {
+
+ uid_ = input.readInt32();
+ break;
+ }
+ default: {
+ if (!parseUnknownField(
+ input, unknownFields, extensionRegistry, tag)) {
+ done = true;
+ }
+ break;
+ }
+ }
+ }
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ throw e.setUnfinishedMessage(this);
+ } catch (java.io.IOException e) {
+ throw new com.google.protobuf.InvalidProtocolBufferException(
+ e).setUnfinishedMessage(this);
+ } finally {
+ this.unknownFields = unknownFields.build();
+ makeExtensionsImmutable();
+ }
+ }
+ public static final com.google.protobuf.Descriptors.Descriptor
+ getDescriptor() {
+ return LrcTimeOuterClass.internal_static_LrcTime_descriptor;
+ }
+
+ @Override
+ protected FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return LrcTimeOuterClass.internal_static_LrcTime_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ LrcTime.class, Builder.class);
+ }
+
+ public static final int TYPE_FIELD_NUMBER = 1;
+ private int type_;
+ /**
+ * .MsgType type = 1;
+ * @return The enum numeric value on the wire for type.
+ */
+ @Override public int getTypeValue() {
+ return type_;
+ }
+ /**
+ * .MsgType type = 1;
+ * @return The type.
+ */
+ @Override public MsgType getType() {
+ @SuppressWarnings("deprecation")
+ MsgType result = MsgType.valueOf(type_);
+ return result == null ? MsgType.UNRECOGNIZED : result;
+ }
+
+ public static final int FORWARD_FIELD_NUMBER = 2;
+ private boolean forward_;
+ /**
+ * bool forward = 2;
+ * @return The forward.
+ */
+ @Override
+ public boolean getForward() {
+ return forward_;
+ }
+
+ public static final int TS_FIELD_NUMBER = 3;
+ private long ts_;
+ /**
+ * int64 ts = 3;
+ * @return The ts.
+ */
+ @Override
+ public long getTs() {
+ return ts_;
+ }
+
+ public static final int SONGID_FIELD_NUMBER = 4;
+ private volatile Object songId_;
+ /**
+ * string songId = 4;
+ * @return The songId.
+ */
+ @Override
+ public String getSongId() {
+ Object ref = songId_;
+ if (ref instanceof String) {
+ return (String) ref;
+ } else {
+ com.google.protobuf.ByteString bs =
+ (com.google.protobuf.ByteString) ref;
+ String s = bs.toStringUtf8();
+ songId_ = s;
+ return s;
+ }
+ }
+ /**
+ * string songId = 4;
+ * @return The bytes for songId.
+ */
+ @Override
+ public com.google.protobuf.ByteString
+ getSongIdBytes() {
+ Object ref = songId_;
+ if (ref instanceof String) {
+ com.google.protobuf.ByteString b =
+ com.google.protobuf.ByteString.copyFromUtf8(
+ (String) ref);
+ songId_ = b;
+ return b;
+ } else {
+ return (com.google.protobuf.ByteString) ref;
+ }
+ }
+
+ public static final int UID_FIELD_NUMBER = 5;
+ private int uid_;
+ /**
+ * int32 uid = 5;
+ * @return The uid.
+ */
+ @Override
+ public int getUid() {
+ return uid_;
+ }
+
+ private byte memoizedIsInitialized = -1;
+ @Override
+ public final boolean isInitialized() {
+ byte isInitialized = memoizedIsInitialized;
+ if (isInitialized == 1) return true;
+ if (isInitialized == 0) return false;
+
+ memoizedIsInitialized = 1;
+ return true;
+ }
+
+ @Override
+ public void writeTo(com.google.protobuf.CodedOutputStream output)
+ throws java.io.IOException {
+ if (type_ != MsgType.UNKNOWN_TYPE.getNumber()) {
+ output.writeEnum(1, type_);
+ }
+ if (forward_ != false) {
+ output.writeBool(2, forward_);
+ }
+ if (ts_ != 0L) {
+ output.writeInt64(3, ts_);
+ }
+ if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(songId_)) {
+ com.google.protobuf.GeneratedMessageV3.writeString(output, 4, songId_);
+ }
+ if (uid_ != 0) {
+ output.writeInt32(5, uid_);
+ }
+ unknownFields.writeTo(output);
+ }
+
+ @Override
+ public int getSerializedSize() {
+ int size = memoizedSize;
+ if (size != -1) return size;
+
+ size = 0;
+ if (type_ != MsgType.UNKNOWN_TYPE.getNumber()) {
+ size += com.google.protobuf.CodedOutputStream
+ .computeEnumSize(1, type_);
+ }
+ if (forward_ != false) {
+ size += com.google.protobuf.CodedOutputStream
+ .computeBoolSize(2, forward_);
+ }
+ if (ts_ != 0L) {
+ size += com.google.protobuf.CodedOutputStream
+ .computeInt64Size(3, ts_);
+ }
+ if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(songId_)) {
+ size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, songId_);
+ }
+ if (uid_ != 0) {
+ size += com.google.protobuf.CodedOutputStream
+ .computeInt32Size(5, uid_);
+ }
+ size += unknownFields.getSerializedSize();
+ memoizedSize = size;
+ return size;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof LrcTime)) {
+ return super.equals(obj);
+ }
+ LrcTime other = (LrcTime) obj;
+
+ if (type_ != other.type_) return false;
+ if (getForward()
+ != other.getForward()) return false;
+ if (getTs()
+ != other.getTs()) return false;
+ if (!getSongId()
+ .equals(other.getSongId())) return false;
+ if (getUid()
+ != other.getUid()) return false;
+ if (!unknownFields.equals(other.unknownFields)) return false;
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ if (memoizedHashCode != 0) {
+ return memoizedHashCode;
+ }
+ int hash = 41;
+ hash = (19 * hash) + getDescriptor().hashCode();
+ hash = (37 * hash) + TYPE_FIELD_NUMBER;
+ hash = (53 * hash) + type_;
+ hash = (37 * hash) + FORWARD_FIELD_NUMBER;
+ hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+ getForward());
+ hash = (37 * hash) + TS_FIELD_NUMBER;
+ hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+ getTs());
+ hash = (37 * hash) + SONGID_FIELD_NUMBER;
+ hash = (53 * hash) + getSongId().hashCode();
+ hash = (37 * hash) + UID_FIELD_NUMBER;
+ hash = (53 * hash) + getUid();
+ hash = (29 * hash) + unknownFields.hashCode();
+ memoizedHashCode = hash;
+ return hash;
+ }
+
+ public static LrcTime parseFrom(
+ java.nio.ByteBuffer data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+ public static LrcTime parseFrom(
+ java.nio.ByteBuffer data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+ public static LrcTime parseFrom(
+ com.google.protobuf.ByteString data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+ public static LrcTime parseFrom(
+ com.google.protobuf.ByteString data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+ public static LrcTime parseFrom(byte[] data)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data);
+ }
+ public static LrcTime parseFrom(
+ byte[] data,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return PARSER.parseFrom(data, extensionRegistry);
+ }
+ public static LrcTime parseFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseWithIOException(PARSER, input);
+ }
+ public static LrcTime parseFrom(
+ java.io.InputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseWithIOException(PARSER, input, extensionRegistry);
+ }
+ public static LrcTime parseDelimitedFrom(java.io.InputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseDelimitedWithIOException(PARSER, input);
+ }
+ public static LrcTime parseDelimitedFrom(
+ java.io.InputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+ }
+ public static LrcTime parseFrom(
+ com.google.protobuf.CodedInputStream input)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseWithIOException(PARSER, input);
+ }
+ public static LrcTime parseFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ return com.google.protobuf.GeneratedMessageV3
+ .parseWithIOException(PARSER, input, extensionRegistry);
+ }
+
+ @Override
+ public Builder newBuilderForType() { return newBuilder(); }
+ public static Builder newBuilder() {
+ return DEFAULT_INSTANCE.toBuilder();
+ }
+ public static Builder newBuilder(LrcTime prototype) {
+ return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+ }
+ @Override
+ public Builder toBuilder() {
+ return this == DEFAULT_INSTANCE
+ ? new Builder() : new Builder().mergeFrom(this);
+ }
+
+ @Override
+ protected Builder newBuilderForType(
+ BuilderParent parent) {
+ Builder builder = new Builder(parent);
+ return builder;
+ }
+ /**
+ * Protobuf type {@code LrcTime}
+ */
+ public static final class Builder extends
+ com.google.protobuf.GeneratedMessageV3.Builder implements
+ // @@protoc_insertion_point(builder_implements:LrcTime)
+ LrcTimeOrBuilder {
+ public static final com.google.protobuf.Descriptors.Descriptor
+ getDescriptor() {
+ return LrcTimeOuterClass.internal_static_LrcTime_descriptor;
+ }
+
+ @Override
+ protected FieldAccessorTable
+ internalGetFieldAccessorTable() {
+ return LrcTimeOuterClass.internal_static_LrcTime_fieldAccessorTable
+ .ensureFieldAccessorsInitialized(
+ LrcTime.class, Builder.class);
+ }
+
+ // Construct using LrcTimeOuterClass.LrcTime.newBuilder()
+ private Builder() {
+ maybeForceBuilderInitialization();
+ }
+
+ private Builder(
+ BuilderParent parent) {
+ super(parent);
+ maybeForceBuilderInitialization();
+ }
+ private void maybeForceBuilderInitialization() {
+ if (com.google.protobuf.GeneratedMessageV3
+ .alwaysUseFieldBuilders) {
+ }
+ }
+ @Override
+ public Builder clear() {
+ super.clear();
+ type_ = 0;
+
+ forward_ = false;
+
+ ts_ = 0L;
+
+ songId_ = "";
+
+ uid_ = 0;
+
+ return this;
+ }
+
+ @Override
+ public com.google.protobuf.Descriptors.Descriptor
+ getDescriptorForType() {
+ return LrcTimeOuterClass.internal_static_LrcTime_descriptor;
+ }
+
+ @Override
+ public LrcTime getDefaultInstanceForType() {
+ return LrcTime.getDefaultInstance();
+ }
+
+ @Override
+ public LrcTime build() {
+ LrcTime result = buildPartial();
+ if (!result.isInitialized()) {
+ throw newUninitializedMessageException(result);
+ }
+ return result;
+ }
+
+ @Override
+ public LrcTime buildPartial() {
+ LrcTime result = new LrcTime(this);
+ result.type_ = type_;
+ result.forward_ = forward_;
+ result.ts_ = ts_;
+ result.songId_ = songId_;
+ result.uid_ = uid_;
+ onBuilt();
+ return result;
+ }
+
+ @Override
+ public Builder clone() {
+ return super.clone();
+ }
+ @Override
+ public Builder setField(
+ com.google.protobuf.Descriptors.FieldDescriptor field,
+ Object value) {
+ return super.setField(field, value);
+ }
+ @Override
+ public Builder clearField(
+ com.google.protobuf.Descriptors.FieldDescriptor field) {
+ return super.clearField(field);
+ }
+ @Override
+ public Builder clearOneof(
+ com.google.protobuf.Descriptors.OneofDescriptor oneof) {
+ return super.clearOneof(oneof);
+ }
+ @Override
+ public Builder setRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field,
+ int index, Object value) {
+ return super.setRepeatedField(field, index, value);
+ }
+ @Override
+ public Builder addRepeatedField(
+ com.google.protobuf.Descriptors.FieldDescriptor field,
+ Object value) {
+ return super.addRepeatedField(field, value);
+ }
+ @Override
+ public Builder mergeFrom(com.google.protobuf.Message other) {
+ if (other instanceof LrcTime) {
+ return mergeFrom((LrcTime)other);
+ } else {
+ super.mergeFrom(other);
+ return this;
+ }
+ }
+
+ public Builder mergeFrom(LrcTime other) {
+ if (other == LrcTime.getDefaultInstance()) return this;
+ if (other.type_ != 0) {
+ setTypeValue(other.getTypeValue());
+ }
+ if (other.getForward() != false) {
+ setForward(other.getForward());
+ }
+ if (other.getTs() != 0L) {
+ setTs(other.getTs());
+ }
+ if (!other.getSongId().isEmpty()) {
+ songId_ = other.songId_;
+ onChanged();
+ }
+ if (other.getUid() != 0) {
+ setUid(other.getUid());
+ }
+ this.mergeUnknownFields(other.unknownFields);
+ onChanged();
+ return this;
+ }
+
+ @Override
+ public final boolean isInitialized() {
+ return true;
+ }
+
+ @Override
+ public Builder mergeFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws java.io.IOException {
+ LrcTime parsedMessage = null;
+ try {
+ parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry);
+ } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+ parsedMessage = (LrcTime) e.getUnfinishedMessage();
+ throw e.unwrapIOException();
+ } finally {
+ if (parsedMessage != null) {
+ mergeFrom(parsedMessage);
+ }
+ }
+ return this;
+ }
+
+ private int type_ = 0;
+ /**
+ * .MsgType type = 1;
+ * @return The enum numeric value on the wire for type.
+ */
+ @Override public int getTypeValue() {
+ return type_;
+ }
+ /**
+ * .MsgType type = 1;
+ * @param value The enum numeric value on the wire for type to set.
+ * @return This builder for chaining.
+ */
+ public Builder setTypeValue(int value) {
+
+ type_ = value;
+ onChanged();
+ return this;
+ }
+ /**
+ * .MsgType type = 1;
+ * @return The type.
+ */
+ @Override
+ public MsgType getType() {
+ @SuppressWarnings("deprecation")
+ MsgType result = MsgType.valueOf(type_);
+ return result == null ? MsgType.UNRECOGNIZED : result;
+ }
+ /**
+ * .MsgType type = 1;
+ * @param value The type to set.
+ * @return This builder for chaining.
+ */
+ public Builder setType(MsgType value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+
+ type_ = value.getNumber();
+ onChanged();
+ return this;
+ }
+ /**
+ * .MsgType type = 1;
+ * @return This builder for chaining.
+ */
+ public Builder clearType() {
+
+ type_ = 0;
+ onChanged();
+ return this;
+ }
+
+ private boolean forward_ ;
+ /**
+ * bool forward = 2;
+ * @return The forward.
+ */
+ @Override
+ public boolean getForward() {
+ return forward_;
+ }
+ /**
+ * bool forward = 2;
+ * @param value The forward to set.
+ * @return This builder for chaining.
+ */
+ public Builder setForward(boolean value) {
+
+ forward_ = value;
+ onChanged();
+ return this;
+ }
+ /**
+ * bool forward = 2;
+ * @return This builder for chaining.
+ */
+ public Builder clearForward() {
+
+ forward_ = false;
+ onChanged();
+ return this;
+ }
+
+ private long ts_ ;
+ /**
+ * int64 ts = 3;
+ * @return The ts.
+ */
+ @Override
+ public long getTs() {
+ return ts_;
+ }
+ /**
+ * int64 ts = 3;
+ * @param value The ts to set.
+ * @return This builder for chaining.
+ */
+ public Builder setTs(long value) {
+
+ ts_ = value;
+ onChanged();
+ return this;
+ }
+ /**
+ * int64 ts = 3;
+ * @return This builder for chaining.
+ */
+ public Builder clearTs() {
+
+ ts_ = 0L;
+ onChanged();
+ return this;
+ }
+
+ private Object songId_ = "";
+ /**
+ * string songId = 4;
+ * @return The songId.
+ */
+ public String getSongId() {
+ Object ref = songId_;
+ if (!(ref instanceof String)) {
+ com.google.protobuf.ByteString bs =
+ (com.google.protobuf.ByteString) ref;
+ String s = bs.toStringUtf8();
+ songId_ = s;
+ return s;
+ } else {
+ return (String) ref;
+ }
+ }
+ /**
+ * string songId = 4;
+ * @return The bytes for songId.
+ */
+ public com.google.protobuf.ByteString
+ getSongIdBytes() {
+ Object ref = songId_;
+ if (ref instanceof String) {
+ com.google.protobuf.ByteString b =
+ com.google.protobuf.ByteString.copyFromUtf8(
+ (String) ref);
+ songId_ = b;
+ return b;
+ } else {
+ return (com.google.protobuf.ByteString) ref;
+ }
+ }
+ /**
+ * string songId = 4;
+ * @param value The songId to set.
+ * @return This builder for chaining.
+ */
+ public Builder setSongId(
+ String value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+
+ songId_ = value;
+ onChanged();
+ return this;
+ }
+ /**
+ * string songId = 4;
+ * @return This builder for chaining.
+ */
+ public Builder clearSongId() {
+
+ songId_ = getDefaultInstance().getSongId();
+ onChanged();
+ return this;
+ }
+ /**
+ * string songId = 4;
+ * @param value The bytes for songId to set.
+ * @return This builder for chaining.
+ */
+ public Builder setSongIdBytes(
+ com.google.protobuf.ByteString value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+ checkByteStringIsUtf8(value);
+
+ songId_ = value;
+ onChanged();
+ return this;
+ }
+
+ private int uid_ ;
+ /**
+ * int32 uid = 5;
+ * @return The uid.
+ */
+ @Override
+ public int getUid() {
+ return uid_;
+ }
+ /**
+ * int32 uid = 5;
+ * @param value The uid to set.
+ * @return This builder for chaining.
+ */
+ public Builder setUid(int value) {
+
+ uid_ = value;
+ onChanged();
+ return this;
+ }
+ /**
+ * int32 uid = 5;
+ * @return This builder for chaining.
+ */
+ public Builder clearUid() {
+
+ uid_ = 0;
+ onChanged();
+ return this;
+ }
+ @Override
+ public final Builder setUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.setUnknownFields(unknownFields);
+ }
+
+ @Override
+ public final Builder mergeUnknownFields(
+ final com.google.protobuf.UnknownFieldSet unknownFields) {
+ return super.mergeUnknownFields(unknownFields);
+ }
+
+
+ // @@protoc_insertion_point(builder_scope:LrcTime)
+ }
+
+ // @@protoc_insertion_point(class_scope:LrcTime)
+ private static final LrcTime DEFAULT_INSTANCE;
+ static {
+ DEFAULT_INSTANCE = new LrcTime();
+ }
+
+ public static LrcTime getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ private static final com.google.protobuf.Parser
+ PARSER = new com.google.protobuf.AbstractParser() {
+ @Override
+ public LrcTime parsePartialFrom(
+ com.google.protobuf.CodedInputStream input,
+ com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+ throws com.google.protobuf.InvalidProtocolBufferException {
+ return new LrcTime(input, extensionRegistry);
+ }
+ };
+
+ public static com.google.protobuf.Parser parser() {
+ return PARSER;
+ }
+
+ @Override
+ public com.google.protobuf.Parser getParserForType() {
+ return PARSER;
+ }
+
+ @Override
+ public LrcTime getDefaultInstanceForType() {
+ return DEFAULT_INSTANCE;
+ }
+
+ }
+
+ private static final com.google.protobuf.Descriptors.Descriptor
+ internal_static_LrcTime_descriptor;
+ private static final
+ com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+ internal_static_LrcTime_fieldAccessorTable;
+
+ public static com.google.protobuf.Descriptors.FileDescriptor
+ getDescriptor() {
+ return descriptor;
+ }
+ private static com.google.protobuf.Descriptors.FileDescriptor
+ descriptor;
+ static {
+ String[] descriptorData = {
+ "\n\rLrcTime.proto\"[\n\007LrcTime\022\026\n\004type\030\001 \001(\016" +
+ "2\010.MsgType\022\017\n\007forward\030\002 \001(\010\022\n\n\002ts\030\003 \001(\003\022" +
+ "\016\n\006songId\030\004 \001(\t\022\013\n\003uid\030\005 \001(\005**\n\007MsgType\022" +
+ "\020\n\014UNKNOWN_TYPE\020\000\022\r\n\010LRC_TIME\020\351\007b\006proto3"
+ };
+ descriptor = com.google.protobuf.Descriptors.FileDescriptor
+ .internalBuildGeneratedFileFrom(descriptorData,
+ new com.google.protobuf.Descriptors.FileDescriptor[] {
+ });
+ internal_static_LrcTime_descriptor =
+ getDescriptor().getMessageTypes().get(0);
+ internal_static_LrcTime_fieldAccessorTable = new
+ com.google.protobuf.GeneratedMessageV3.FieldAccessorTable(
+ internal_static_LrcTime_descriptor,
+ new String[] { "Type", "Forward", "Ts", "SongId", "Uid", });
+ }
+
+ // @@protoc_insertion_point(outer_class_scope)
+}
diff --git a/KTVAPI/Android/settings.gradle b/KTVAPI/Android/settings.gradle
index 74bdad4..20704c2 100644
--- a/KTVAPI/Android/settings.gradle
+++ b/KTVAPI/Android/settings.gradle
@@ -15,4 +15,5 @@ dependencyResolutionManagement {
rootProject.name = "KtvApiDemo"
include ':app'
-include ':lib_ktvapi'
\ No newline at end of file
+include ':lib_ktvapi'
+include ':lib_ktvapi_ex'
\ No newline at end of file