feat: live lyrics from notification title, fix topbar overlap

This commit is contained in:
2026-08-16 21:06:08 +08:00
parent fe2dcabdd4
commit 18a237bd1f
3 changed files with 54 additions and 13 deletions
@@ -51,9 +51,11 @@ class MainActivity : Activity() {
// Placeholder manifest/catalog URLs; replace with the real server on device // Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json" private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json" private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
// 内容 WebView 顶栏预留高度(dp) // 内容 WebView 顶栏预留高度(dp)H5 顶栏 top 12px + 按钮 30px + padding,约 54dp
// Topbar reserved height for content WebViews (dp) // 取 56dp 留余量,避免内容 WebView 覆盖顶栏
private const val TOPBAR_HEIGHT_DP = 44 // Topbar reserved height (dp): H5 topbar top 12px + button 30px + padding ≈ 54dp;
// use 56dp with margin so the content WebView doesn't cover the topbar
private const val TOPBAR_HEIGHT_DP = 56
// 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏 // 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏
// Global sidebar collapsed width (dp); content WebViews reserve it on the // Global sidebar collapsed width (dp); content WebViews reserve it on the
// left to avoid covering the sidebar // left to avoid covering the sidebar
@@ -1,13 +1,34 @@
package top.yeij.hearth.media package top.yeij.hearth.media
import android.app.Notification
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
// 通知监听服务:空实现,仅作为「通知使用权」授权凭据, // 通知监听服务:作为「通知使用权」授权凭据(让 getActiveSessions 可用)
// 让 MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent) // 同时监听媒体通知的实时标题变化(如网易云歌词实时显示在通知标题)。
// 能拿到活跃媒体会话(否则普通 APK 拿不到 MEDIA_CONTENT_CONTROL 系统权限)。 // Notification listener service: serves as the "notification access" credential
// Notification listener service: empty implementation, only serves as the // (making getActiveSessions usable), and also watches media notification title
// "notification access" authorization credential so that // changes (e.g. NetEase Cloud Music shows live lyrics in the notification title).
// MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent) class HearthNotificationListenerService : NotificationListenerService() {
// can receive active media sessions (a normal APK cannot hold the
// MEDIA_CONTENT_CONTROL system permission). companion object {
class HearthNotificationListenerService : NotificationListenerService() private const val TAG = "HearthNotifListener"
// 媒体通知实时标题回调(packageName -> title),供 MediaSessionSource 订阅
// Media notification live-title callback (packageName -> title), subscribed by MediaSessionSource
@Volatile
var onMediaTitle: ((packageName: String, title: String) -> Unit)? = null
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn ?: return
val n = sbn.notification ?: return
// 仅处理媒体通知(CATEGORY_TRANSPORT
// Only handle media notifications (CATEGORY_TRANSPORT)
if (n.category != Notification.CATEGORY_TRANSPORT) return
val title = n.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: return
Log.d(TAG, "onNotificationPosted: ${sbn.packageName} title=$title")
onMediaTitle?.invoke(sbn.packageName, title)
}
}
@@ -31,6 +31,20 @@ class MediaSessionSource(context: Context) {
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission. // replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java) private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
// 通知实时标题缓存(packageName -> title),来自通知栏的实时歌词,
// 优先于 MediaSession 的 METADATA_KEY_TITLE(歌词只在通知里更新)
// Live notification title cache (packageName -> title) from the notification
// shade; it takes priority over MediaSession METADATA_KEY_TITLE because lyrics
// only update in the notification
private val notificationTitles = mutableMapOf<String, String>()
init {
HearthNotificationListenerService.onMediaTitle = { pkg, title ->
notificationTitles[pkg] = title
pushCurrent()
}
}
private var callback: ((List<MediaInfo>) -> Unit)? = null private var callback: ((List<MediaInfo>) -> Unit)? = null
private var registered = false private var registered = false
private val controllers = mutableListOf<MediaController>() private val controllers = mutableListOf<MediaController>()
@@ -194,8 +208,12 @@ class MediaSessionSource(context: Context) {
private fun toMediaInfo(c: MediaController): MediaInfo { private fun toMediaInfo(c: MediaController): MediaInfo {
val meta = c.metadata val meta = c.metadata
val state = c.playbackState val state = c.playbackState
// 标题优先用通知实时标题(歌词),fallback 到 MediaSession 元数据
// Title prefers the live notification title (lyrics), falling back to MediaSession metadata
val title = notificationTitles[c.packageName]
?: (meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "")
return MediaInfo( return MediaInfo(
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "", title,
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "", meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "", meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) } ?: "", meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) } ?: "",