feat: media session source and media card
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
|
||||
<application
|
||||
android:label="Hearth"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 = `<div class="cap">正在播放</div>
|
||||
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div>
|
||||
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`;
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user