跳转至

开发示例

本页提供基于 Media3 MediaBrowser 的真实接入示例。每个媒体源对外接口都有对应的内部测试入口,外部接入方可参考下面的代码片段进行验证。

1. 最小可运行示例

import android.content.ComponentName
import android.content.Context
import androidx.media3.session.MediaBrowser
import androidx.media3.session.SessionToken
import kotlinx.coroutines.guava.await

class MediaCenterDemo(private val context: Context) {

    private val neteaseSource = ComponentName(
        "com.jidouauto.netease.jdo",
        "com.jidouauto.netease.jdo.service.JdoMusicMediaService"
    )

    suspend fun playFirstSong(): Boolean {
        val token = SessionToken(context, neteaseSource)
        val browser = MediaBrowser.Builder(context, token).buildAsync().await()
        try {
            val root = browser.getLibraryRoot(null).await().value ?: return false
            val children = browser.getChildren(root.mediaId, 1, 50, null).await().value.orEmpty()
            val playable = children.firstOrNull { it.mediaMetadata.isPlayable == true } ?: return false
            browser.setMediaItem(playable)
            browser.prepare()
            browser.play()
            return true
        } finally {
            browser.release()
        }
    }
}

2. 搜索 + 整组播放(模拟语音助手"播放林俊杰的歌")

import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaBrowser
import kotlinx.coroutines.guava.await

private fun extractChildren(group: MediaItem): List<MediaItem> {
    val bundles = group.mediaMetadata.extras
        ?.getParcelableArrayList<Bundle>("MEDIA_ITEM_PARAMETER_CHILDREN")
        ?: return emptyList()
    return bundles.map { MediaItem.fromBundle(it) }
}

suspend fun playArtistSongs(browser: MediaBrowser, artist: String) {
    val result = browser.getSearchResult(artist, 1, 8, null).await()
    val songGroup = result.value
        .orEmpty()
        .firstOrNull { it.mediaId == "searchsong" }
        ?: return

    val songs = extractChildren(songGroup)
    if (songs.isEmpty()) return

    browser.setMediaItems(songs, 0, 0L)
    browser.prepare()
    browser.play()
}

3. 视频播放(模拟语音助手"播放西游记")

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaBrowser
import kotlinx.coroutines.guava.await

private fun extractChildren(group: MediaItem): List<MediaItem> {
    val bundles = group.mediaMetadata.extras
        ?.getParcelableArrayList<Bundle>("MEDIA_ITEM_PARAMETER_CHILDREN")
        ?: return emptyList()
    return bundles.map { MediaItem.fromBundle(it) }
}

suspend fun launchIqyVideo(context: Context, browser: MediaBrowser, keyword: String) {
    val result = browser.getSearchResult(keyword, 1, 6, null).await()
    val videoGroup = result.value
        .orEmpty()
        .firstOrNull { it.mediaId == "videos" }
        ?: return

    val target = extractChildren(videoGroup).firstOrNull() ?: return

    val intent = Intent("com.jidouauto.iqiyi.LAUNCH_INTENT").apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        putExtra("target_media_item", target.toBundle())
    }
    context.startActivity(intent)
}

4. 监听播放状态

import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaBrowser

class PlaybackObserver(private val browser: MediaBrowser) : Player.Listener {

    fun attach() = browser.addListener(this)
    fun detach() = browser.removeListener(this)

    override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
        // 切歌:刷新 UI、重新拉歌词
    }

    override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
        // metadata 更新:标题、封面、艺人
    }

    override fun onPlaybackStateChanged(playbackState: Int) {
        // STATE_IDLE / STATE_BUFFERING / STATE_READY / STATE_ENDED
    }

    override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
        // 播放/暂停状态变化
    }
}

5. 进度轮询

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

fun CoroutineScope.startProgressTicker(browser: MediaBrowser, onTick: (Long, Long) -> Unit) =
    launch {
        while (isActive) {
            val pos = browser.currentPosition
            val dur = browser.contentDuration
            onTick(pos, dur)
            delay(250)
        }
    }

6. 收藏与登录态处理

import androidx.media3.common.HeartRating
import androidx.media3.session.MediaBrowser
import androidx.media3.session.SessionResult
import kotlinx.coroutines.guava.await

sealed class FavoriteResult {
    data object Ok : FavoriteResult()
    data object NeedLogin : FavoriteResult()
    data object Failed : FavoriteResult()
}

suspend fun toggleFavorite(
    browser: MediaBrowser,
    mediaId: String,
    favorite: Boolean,
): FavoriteResult {
    val result: SessionResult = browser
        .setRating(mediaId, HeartRating(favorite))
        .await()

    if (result.resultCode == SessionResult.RESULT_SUCCESS) {
        return FavoriteResult.Ok
    }
    if (result.extras.getBoolean("requireLogin", false)) {
        return FavoriteResult.NeedLogin
    }
    return FavoriteResult.Failed
}

7. 获取歌词(普通 LRC + 逐字 JSON)

import android.os.Bundle
import androidx.media3.common.MediaMetadata
import androidx.media3.session.MediaBrowser
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import kotlinx.coroutines.guava.await

object MediaLyricConstants {
    const val CUSTOM_ACTION_FETCH_LYRIC = "CUSTOM_ACTION_FETCH_LYRIC"
    const val CUSTOM_ACTION_FETCH_LYRIC_BY_WORD = "CUSTOM_ACTION_FETCH_LYRIC_BY_WORD"
    const val EXTRA_MUSIC_LYRIC = "music_lyric"
}

suspend fun fetchLyric(
    browser: MediaBrowser,
    metadata: MediaMetadata,
    byWord: Boolean,
): String? {
    if (metadata.mediaType != MediaMetadata.MEDIA_TYPE_MUSIC) return null

    val action = if (byWord) {
        MediaLyricConstants.CUSTOM_ACTION_FETCH_LYRIC_BY_WORD
    } else {
        MediaLyricConstants.CUSTOM_ACTION_FETCH_LYRIC
    }

    val command = SessionCommand(action, Bundle.EMPTY)
    if (!browser.isSessionCommandAvailable(command)) return null

    val result: SessionResult = browser.sendCustomCommand(command, metadata.toBundle()).await()
    if (result.resultCode != SessionResult.RESULT_SUCCESS) return null

    return result.extras.getString(MediaLyricConstants.EXTRA_MUSIC_LYRIC)?.takeIf { it.isNotBlank() }
}

完整歌词解析说明请见 歌词对接

8. 多源切换

import android.content.ComponentName

object MediaSourceCatalog {
    val NETEASE = ComponentName(
        "com.jidouauto.netease.jdo",
        "com.jidouauto.netease.jdo.service.JdoMusicMediaService",
    )
    val IQY = ComponentName(
        "com.jidouauto.iqiyi.jdo",
        "com.jidouauto.iqiyi.jdo.service.JdoIqiyiMediaService",
    )
    val RADIO = ComponentName(
        "com.jidouauto.radiobrowser.jdo",
        "com.jidouauto.radiobrowser.jdo.service.JdoRadioBrowserMediaService",
    )
    val SPOTIFY = ComponentName(
        "com.jidouauto.spotify",
        "com.jidouauto.spotify.service.SpotifyMediaService",
    )
    val SHORT_PLAY = ComponentName(
        "com.jidouauto.shortplay.jdo",
        "com.jidouauto.shortplay.jdo.service.JdoShortPlayMediaService",
    )
}

class MultiSourceClient(private val context: Context) {
    private var currentBrowser: MediaBrowser? = null

    suspend fun switchTo(source: ComponentName): MediaBrowser {
        currentBrowser?.release()
        val token = SessionToken(context, source)
        return MediaBrowser.Builder(context, token)
            .buildAsync()
            .await()
            .also { currentBrowser = it }
    }

    fun release() {
        currentBrowser?.release()
        currentBrowser = null
    }
}

示例代码来源

上面的示例都来自媒体中心 MediaBrowserManager / PlaybackHelper / ServiceCommandHandler 的真实接入逻辑。每个媒体源对外都暴露了对应的协议常量和 MediaSessionService,外部接入方均可按这些示例直接验证。