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) -> Unit)? = null private var registered = false private val controllers = mutableListOf() // 轮询兜底: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) -> 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) { 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" } }