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