224 lines
9.7 KiB
Kotlin
224 lines
9.7 KiB
Kotlin
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")
|
||
}
|
||
|
||
// 跳转到指定会话的播放界面:优先 sessionActivity(PendingIntent),
|
||
// 失败则回退到启动该 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,
|
||
)
|
||
}
|
||
|
||
// 封面缩放到 128px,JPEG 压缩转 base64(data 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"
|
||
}
|
||
}
|