feat: media card cover, controls, live progress, multi-session swipe
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
package top.yeij.hearth.media
|
||||
|
||||
// 媒体会话快照:标题/艺术家/专辑 + 播放状态/进度 + 来源包名
|
||||
// Media session snapshot: title/artist/album + playback state/progress + source package
|
||||
// 媒体会话快照:标题/艺术家/专辑 + 封面 + 播放状态/进度 + 来源包名
|
||||
// Media session snapshot: title/artist/album + cover + playback state/progress + source package
|
||||
data class MediaInfo(
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String,
|
||||
val cover: String,
|
||||
val playing: Boolean,
|
||||
val position: Long,
|
||||
val duration: Long,
|
||||
|
||||
@@ -2,14 +2,21 @@ package top.yeij.hearth.media
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
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.util.Base64
|
||||
import android.util.Log
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
// 监听系统活跃媒体会话,取第一个 controller 的 metadata + playbackState 构造 MediaInfo 回调
|
||||
// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState
|
||||
// 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
|
||||
// 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
|
||||
// 提供播放控制(上一首/暂停播放/下一首)与封面提取。
|
||||
// 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) and cover extraction.
|
||||
class MediaSessionSource(context: Context) {
|
||||
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
|
||||
|
||||
@@ -19,39 +26,50 @@ class MediaSessionSource(context: Context) {
|
||||
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
|
||||
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
|
||||
|
||||
private var callback: ((MediaInfo?) -> Unit)? = null
|
||||
private var callback: ((List<MediaInfo>) -> Unit)? = null
|
||||
private var registered = false
|
||||
private val controllers = mutableListOf<MediaController>()
|
||||
|
||||
// 具名监听器字段:会话列表变化时回调
|
||||
// Named listener field: fires when the active-session list changes
|
||||
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
|
||||
// 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 { controllers ->
|
||||
val cb = callback ?: return@OnActiveSessionsChangedListener
|
||||
notifyMedia(controllers?.firstOrNull(), cb)
|
||||
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: playback that started
|
||||
// before registration never fires the change callback, so the active pull makes the
|
||||
// media card show immediately on startup.
|
||||
fun start(cb: (MediaInfo?) -> Unit) {
|
||||
// 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")
|
||||
val current = msm.getActiveSessions(listenerComponent).firstOrNull()
|
||||
notifyMedia(current, cb)
|
||||
updateControllers(msm.getActiveSessions(listenerComponent))
|
||||
pushCurrent()
|
||||
} catch (e: SecurityException) {
|
||||
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
|
||||
// 用户在系统设置开启通知使用权后,重启 Hearth 才会恢复媒体卡。
|
||||
// MediaSessionService throws SecurityException without notification access;
|
||||
// degrade to media listening disabled instead of crashing. Media card recovers
|
||||
// after the user grants notification access and restarts Hearth.
|
||||
// degrade to media listening disabled instead of crashing.
|
||||
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
|
||||
registered = false
|
||||
}
|
||||
@@ -65,44 +83,92 @@ class MediaSessionSource(context: Context) {
|
||||
registered = false
|
||||
Log.d(TAG, "stop: media session listener removed")
|
||||
}
|
||||
unregisterCallbacks()
|
||||
callback = null
|
||||
}
|
||||
|
||||
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
|
||||
// Actively pull the current session and notify (without re-registering), for
|
||||
// refreshing the media card once the H5 page is ready
|
||||
// Actively pull the current sessions and notify (without re-registering)
|
||||
fun refresh() {
|
||||
val cb = callback ?: return
|
||||
if (callback == null) return
|
||||
try {
|
||||
val current = msm.getActiveSessions(listenerComponent).firstOrNull()
|
||||
notifyMedia(current, cb)
|
||||
updateControllers(msm.getActiveSessions(listenerComponent))
|
||||
pushCurrent()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "refresh: no notification access: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 从 controller 提取 metadata + playbackState 构造 MediaInfo 并回调
|
||||
// Build MediaInfo from the controller's metadata + playbackState and invoke the callback
|
||||
private fun notifyMedia(controller: MediaController?, cb: (MediaInfo?) -> Unit) {
|
||||
if (controller == null) {
|
||||
Log.d(TAG, "no active media session")
|
||||
cb(null)
|
||||
return
|
||||
// 播放控制:作用于第一个(当前展示)controller
|
||||
// Transport controls: act on the first (currently shown) controller
|
||||
fun previous() {
|
||||
controllers.firstOrNull()?.transportControls?.skipToPrevious()
|
||||
Log.d(TAG, "previous")
|
||||
}
|
||||
|
||||
fun next() {
|
||||
controllers.firstOrNull()?.transportControls?.skipToNext()
|
||||
Log.d(TAG, "next")
|
||||
}
|
||||
|
||||
fun playPause() {
|
||||
val c = controllers.firstOrNull() ?: return
|
||||
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
|
||||
if (playing) c.transportControls.pause() else c.transportControls.play()
|
||||
Log.d(TAG, "playPause: playing=$playing")
|
||||
}
|
||||
|
||||
// 更新 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
|
||||
}
|
||||
}
|
||||
val meta = controller.metadata
|
||||
val state = controller.playbackState
|
||||
cb(
|
||||
MediaInfo(
|
||||
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
|
||||
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
|
||||
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
|
||||
state?.state == PlaybackState.STATE_PLAYING,
|
||||
state?.position ?: 0L,
|
||||
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
|
||||
controller.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
// 推送当前全部会话的 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,
|
||||
)
|
||||
Log.d(TAG, "media: ${controller.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 {
|
||||
|
||||
@@ -304,7 +304,7 @@ class JsBridge(
|
||||
|
||||
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
|
||||
mediaSourceRef = source
|
||||
source.start { info -> pushMedia(info, webView) }
|
||||
source.start { infos -> pushMedia(infos, webView) }
|
||||
}
|
||||
|
||||
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
|
||||
@@ -314,9 +314,26 @@ class JsBridge(
|
||||
mediaSourceRef?.refresh()
|
||||
}
|
||||
|
||||
private fun pushMedia(info: MediaInfo?, webView: android.webkit.WebView) {
|
||||
// 媒体播放控制(作用于当前展示的会话)
|
||||
// Media transport controls (act on the currently shown session)
|
||||
@android.webkit.JavascriptInterface
|
||||
fun mediaPrevious() {
|
||||
mediaSourceRef?.previous()
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun mediaPlayPause() {
|
||||
mediaSourceRef?.playPause()
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun mediaNext() {
|
||||
mediaSourceRef?.next()
|
||||
}
|
||||
|
||||
private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {
|
||||
webView.post {
|
||||
val json = info?.toJson() ?: "null"
|
||||
val json = gson.toJson(infos)
|
||||
webView.evaluateJavascript(
|
||||
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
|
||||
null
|
||||
|
||||
Reference in New Issue
Block a user