Files
Hearth/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
T

224 lines
9.7 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import java.io.ByteArrayOutputStream
// 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
// 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
// 提供播放控制(上一首/暂停播放/下一首)、封面提取与跳转播放界面。
// Watch active media sessions (supports multiple: music/audiobook/video at once),
// push in real time on metadata/playback-state change (track switch, play/pause);
// provide transport controls (previous/play-pause/next), cover extraction and
// jumping to the playback UI.
class MediaSessionSource(context: Context) {
private val appContext = context.applicationContext
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
// 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
// 取代依赖 MEDIA_CONTENT_CONTROL 系统权限的 null 方案。
// The notification listener component is passed as the authorization credential,
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
private var callback: ((List<MediaInfo>) -> Unit)? = null
private var registered = false
private val controllers = mutableListOf<MediaController>()
// 轮询兜底:registerCallback 在部分场景不触发(如歌词实时更新时),
// 每 2 秒主动拉取一次进度/状态,保证进度条与信息持续更新
// Polling fallback: registerCallback doesn't always fire (e.g. live lyrics),
// so actively pull progress/state every 2s to keep the bar and info fresh
private val pollHandler = Handler(Looper.getMainLooper())
private val pollRunnable = object : Runnable {
override fun run() {
refresh()
pollHandler.postDelayed(this, 2000)
}
}
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
// Metadata/playback-state change callback: push in real time on track switch or play/pause
private val mediaCallback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) {
Log.d(TAG, "onMetadataChanged: ${metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)}")
pushCurrent()
}
override fun onPlaybackStateChanged(state: PlaybackState?) {
Log.d(TAG, "onPlaybackStateChanged: state=${state?.state}")
pushCurrent()
}
}
// 活跃会话列表变化时回调
// Fires when the active-session list changes
private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { list ->
if (callback == null) return@OnActiveSessionsChangedListener
updateControllers(list?.toList() ?: emptyList())
pushCurrent()
}
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
// 注册后主动拉取一次当前会话:注册前已开始的播放不会触发变化回调。
// Register the listener: idempotent — remove the old one first to avoid accumulation.
// After registering, actively pull the current sessions once.
fun start(cb: (List<MediaInfo>) -> Unit) {
stop()
callback = cb
try {
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true
Log.d(TAG, "start: media session listener registered")
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
// 启动轮询兜底
pollHandler.removeCallbacks(pollRunnable)
pollHandler.postDelayed(pollRunnable, 2000)
} catch (e: SecurityException) {
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
// MediaSessionService throws SecurityException without notification access;
// degrade to media listening disabled instead of crashing.
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
registered = false
}
}
// 注销监听:释放回调引用,供 Activity onDestroy 调用
// Unregister the listener: release the callback, call from Activity onDestroy
fun stop() {
if (registered) {
msm.removeOnActiveSessionsChangedListener(activeSessionsListener)
registered = false
Log.d(TAG, "stop: media session listener removed")
}
unregisterCallbacks()
pollHandler.removeCallbacks(pollRunnable)
callback = null
}
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
// Actively pull the current sessions and notify (without re-registering)
fun refresh() {
if (callback == null) return
try {
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
} catch (e: SecurityException) {
Log.w(TAG, "refresh: no notification access: ${e.message}")
}
}
// 播放控制:作用于指定索引的 controller(对应 H5 当前滑到的会话)
// Transport controls: act on the controller at the given index (matching the
// session the H5 card is currently showing)
fun previous(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToPrevious()
Log.d(TAG, "previous: index=$index")
}
fun next(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToNext()
Log.d(TAG, "next: index=$index")
}
fun playPause(index: Int) {
val c = controllers.getOrNull(index) ?: return
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
if (playing) c.transportControls.pause() else c.transportControls.play()
Log.d(TAG, "playPause: index=$index playing=$playing")
}
// 跳转到指定会话的播放界面:优先 sessionActivityPendingIntent),
// 失败则回退到启动该 App
// Jump to the session's playback UI: prefer sessionActivity (PendingIntent),
// fall back to launching the app
fun openMediaApp(index: Int) {
val c = controllers.getOrNull(index) ?: return
val sessionActivity = c.sessionActivity
if (sessionActivity != null) {
try {
sessionActivity.send()
Log.d(TAG, "openMediaApp: sessionActivity sent for ${c.packageName}")
return
} catch (e: Exception) {
Log.w(TAG, "openMediaApp: sessionActivity failed: ${e.message}")
}
}
val launch = appContext.packageManager.getLaunchIntentForPackage(c.packageName)
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(launch)
Log.d(TAG, "openMediaApp: launched ${c.packageName}")
}
}
// 更新 controller 列表:注销旧回调、注册新回调
// Update the controller list: unregister old callbacks, register new ones
private fun updateControllers(list: List<MediaController>) {
unregisterCallbacks()
controllers.clear()
controllers.addAll(list)
controllers.forEach { it.registerCallback(mediaCallback) }
}
private fun unregisterCallbacks() {
controllers.forEach {
try {
it.unregisterCallback(mediaCallback)
} catch (e: Exception) {
// ignore
}
}
}
// 推送当前全部会话的 MediaInfo 列表给 H5
// Push the current MediaInfo list for all sessions to H5
private fun pushCurrent() {
callback?.let { cb -> cb(controllers.map { toMediaInfo(it) }) }
}
// 从 controller 提取 metadata + playbackState + 封面构造 MediaInfo
// Build MediaInfo from the controller's metadata + playbackState + cover
private fun toMediaInfo(c: MediaController): MediaInfo {
val meta = c.metadata
val state = c.playbackState
return MediaInfo(
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) } ?: "",
state?.state == PlaybackState.STATE_PLAYING,
state?.position ?: 0L,
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
c.packageName,
)
}
// 封面缩放到 128pxJPEG 压缩转 base64data URI
// Scale the cover to 128px, JPEG-compress to base64 (data URI)
private fun bitmapToBase64(bmp: Bitmap): String {
val size = 128
val scaled = Bitmap.createScaledBitmap(bmp, size, size, true)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
scaled.recycle()
return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
companion object {
private const val TAG = "HearthMedia"
}
}