From b157dac7ae85b0f3d771d19fe351cd1de316a60b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 16 Aug 2026 16:49:41 +0800 Subject: [PATCH] feat: media session source and media card --- app/src/main/AndroidManifest.xml | 1 + app/src/main/assets/h5/css/app.css | 40 ++++++++++++++++++ app/src/main/assets/h5/js/pages/home.js | 42 +++++++++++++++++++ .../java/top/yeij/hearth/media/MediaInfo.kt | 15 +++++++ .../yeij/hearth/media/MediaSessionSource.kt | 41 ++++++++++++++++++ .../java/top/yeij/hearth/webview/JsBridge.kt | 15 +++++++ 6 files changed, 154 insertions(+) create mode 100644 app/src/main/java/top/yeij/hearth/media/MediaInfo.kt create mode 100644 app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6599e2a..6d5bc3e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css index fea53a6..d13fa0c 100644 --- a/app/src/main/assets/h5/css/app.css +++ b/app/src/main/assets/h5/css/app.css @@ -105,6 +105,46 @@ body { color: var(--text-dim); } +/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */ +/* Media card: playing caption + title/artist + progress bar */ +.media-card .cap { + font-size: 11px; + color: var(--text-dim); + letter-spacing: 1px; +} + +.media-card .tt { + margin-top: 6px; + font-size: 18px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-card .ar { + margin-top: 2px; + font-size: 13px; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-card .prog { + margin-top: 12px; + height: 4px; + border-radius: 999px; + background: var(--glass-border); + overflow: hidden; +} + +.media-card .bar { + height: 100%; + border-radius: 999px; + background: var(--accent); +} + /* 安卓 APP 列表页:搜索框 + 网格 */ /* Android app list page: search box + grid */ .search { padding: 16px; } diff --git a/app/src/main/assets/h5/js/pages/home.js b/app/src/main/assets/h5/js/pages/home.js index 81cb11d..20eb0f4 100644 --- a/app/src/main/assets/h5/js/pages/home.js +++ b/app/src/main/assets/h5/js/pages/home.js @@ -74,3 +74,45 @@ function startTimeCardTicker() { card.querySelector('.sub').textContent = fmtDate(now); }, 10000); } + +// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局 +// Native media session event: render media card in middle column when present, +// otherwise fall back to re-layout (calendar/weather fill the media card slot) +window.HearthEvents = window.HearthEvents || {}; +window.HearthEvents.mediaSessionChanged = function (info) { + const mediaCol = document.getElementById('col-1'); + if (!mediaCol) return; + if (info) { + mediaCol.innerHTML = ''; + mediaCol.appendChild(renderMediaCard(info)); + } else { + // 无媒体:降级到日历/天气(由 cards.layout 重新计算) + // No media: fall back to calendar/weather (recomputed by cards.layout) + window.updateHomeCards(); + } +}; + +// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 +// Media card: playing caption + title/artist + progress bar +function renderMediaCard(info) { + const el = document.createElement('div'); + el.className = 'card media-card'; + const pct = info.duration ? (info.position / info.duration) * 100 : 0; + // title/artist 来自任意应用元数据,转义后插入,防 XSS + // title/artist come from arbitrary app metadata; escape before insert (XSS) + el.innerHTML = `
正在播放
+
${escapeHtml(info.title)}
${escapeHtml(info.artist)}
+
`; + return el; +} + +// 转义 HTML 特殊字符,避免媒体元数据注入标签 +// Escape HTML special chars to prevent metadata injection +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt b/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt new file mode 100644 index 0000000..a7c3224 --- /dev/null +++ b/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt @@ -0,0 +1,15 @@ +package top.yeij.hearth.media + +// 媒体会话快照:标题/艺术家/专辑 + 播放状态/进度 + 来源包名 +// Media session snapshot: title/artist/album + playback state/progress + source package +data class MediaInfo( + val title: String, + val artist: String, + val album: String, + val playing: Boolean, + val position: Long, + val duration: Long, + val packageName: String, +) { + fun toJson(): String = com.google.gson.Gson().toJson(this) +} diff --git a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt new file mode 100644 index 0000000..a3cc06f --- /dev/null +++ b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt @@ -0,0 +1,41 @@ +package top.yeij.hearth.media + +import android.content.Context +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.util.Log + +// 监听系统活跃媒体会话,取第一个 controller 的 metadata + playbackState 构造 MediaInfo 回调 +// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState +class MediaSessionSource(context: Context) { + private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager + private var listener: ((MediaInfo?) -> Unit)? = null + + fun start(cb: (MediaInfo?) -> Unit) { + listener = cb + msm.addOnActiveSessionsChangedListener({ controllers -> + val c = controllers?.firstOrNull() + if (c == null) { + Log.d("HearthMedia", "no active media session") + cb(null) + return@addOnActiveSessionsChangedListener + } + val meta = c.metadata + val state = c.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, + c.packageName, + ) + ) + Log.d("HearthMedia", "media: ${c.packageName}") + }, null) + } +} diff --git a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt index a0aa422..e1e0697 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -4,6 +4,7 @@ import android.util.Log import top.yeij.hearth.app.AppRepository import top.yeij.hearth.card.Card import top.yeij.hearth.card.CardRepository +import top.yeij.hearth.media.MediaSessionSource import top.yeij.hearth.webapp.WebApp import top.yeij.hearth.webapp.WebAppRepository @@ -55,4 +56,18 @@ class JsBridge( Log.d("HearthBridge", "fetchCards: ${cards.size} cards") return gson.toJson(cards) } + + // 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null") + // Register media session listener; push callbacks to H5 (push "null" when info is null) + fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) { + source.start { info -> + webView.post { + val json = info?.toJson() ?: "null" + webView.evaluateJavascript( + "window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", + null + ) + } + } + } }