feat: media card cover, controls, live progress, multi-session swipe

This commit is contained in:
2026-08-16 19:59:30 +08:00
parent 8c42dca7bc
commit 5f5d0431ee
5 changed files with 248 additions and 65 deletions
+25
View File
@@ -167,6 +167,10 @@ body {
font-size: 56px; font-size: 56px;
font-weight: 300; font-weight: 300;
line-height: 1; line-height: 1;
/* 等宽数字:1 和 0 等宽,避免时间刷新时卡片宽度抖动 */
/* Tabular numerals: 1 and 0 are equal-width, avoiding card width jitter on refresh */
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
} }
/* 秒显:小号、弱化,紧跟时:分之后 */ /* 秒显:小号、弱化,紧跟时:分之后 */
@@ -217,6 +221,27 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* 媒体卡:封面 + 信息横排 */
/* Media card: cover + info row */
.media-card .media-row {
display: flex;
gap: 12px;
align-items: center;
}
.media-card .cover {
width: 56px;
height: 56px;
border-radius: 12px;
object-fit: cover;
flex: none;
}
.media-card .media-info {
flex: 1;
min-width: 0;
}
.media-card .prog { .media-card .prog {
margin-top: auto; margin-top: auto;
padding-top: 12px; padding-top: 12px;
+90 -16
View File
@@ -36,7 +36,9 @@ function renderCards(catalog) {
// time 卡(内置,span 1 // time 卡(内置,span 1
if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() }); if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() });
// media 卡(有媒体时 span 2,跨栏更美观) // media 卡(有媒体时 span 2,跨栏更美观)
if (currentMedia) items.push({ span: 2, el: renderMediaCard(currentMedia) }); if (currentMediaList && currentMediaList.length > 0) {
items.push({ span: 2, el: renderMediaCard(currentMediaList[currentMediaIndex]) });
}
// 其他卡(排除 timespan 1 // 其他卡(排除 timespan 1
sorted.filter(c => c.id !== 'time').forEach(c => { sorted.filter(c => c.id !== 'time').forEach(c => {
items.push({ span: 1, el: renderGenericCard(c.id) }); items.push({ span: 1, el: renderGenericCard(c.id) });
@@ -96,37 +98,109 @@ function startTimeCardTicker() {
}, 1000); }, 1000);
} }
// 当前媒体信息(null 表示无媒体);切页后 updateHomeCards 据此恢复媒体卡 // 当前媒体会话列表(多个:音乐/听书/视频)+ 当前展示索引
// Current media info (null = none); updateHomeCards restores the media card from it // Current media session list (multiple: music/audiobook/video) + shown index
let currentMedia = null; let currentMediaList = null;
let currentMediaIndex = 0;
// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局 // 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
// Native media session event: render media card in middle column when present, // Native media session event: receives the session list; render media card when
// otherwise fall back to re-layout (calendar/weather fill the media card slot) // present, otherwise fall back to re-layout
window.HearthEvents = window.HearthEvents || {}; window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (info) { window.HearthEvents.mediaSessionChanged = function (infos) {
currentMedia = info; if (!infos || infos.length === 0) {
currentMediaList = null;
currentMediaIndex = 0;
} else {
currentMediaList = infos;
if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
}
const grid = document.getElementById('tri-col'); const grid = document.getElementById('tri-col');
if (!grid) return; if (!grid) return;
// 重新渲染:有媒体时 media 卡 span 2,无媒体时其他卡填满
// Re-render: media card spans 2 when present, others fill otherwise
window.updateHomeCards(); window.updateHomeCards();
}; };
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 // 媒体卡:封面 + 标题/艺术家 + 进度条 + 控制按钮 + 多会话滑动切换
// Media card: playing caption + title/artist + progress bar // Media card: cover + title/artist + progress + controls + multi-session swipe
function renderMediaCard(info) { function renderMediaCard(info) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'card media-card'; el.className = 'card media-card';
const pct = info.duration ? (info.position / info.duration) * 100 : 0; const coverHtml = info.cover
? `<img class="cover" src="${info.cover}" alt="">`
: '';
// title/artist 来自任意应用元数据,转义后插入,防 XSS // title/artist 来自任意应用元数据,转义后插入,防 XSS
// title/artist come from arbitrary app metadata; escape before insert (XSS) // title/artist come from arbitrary app metadata; escape before insert (XSS)
el.innerHTML = `<div class="cap">正在播放</div> el.innerHTML = `<div class="cap">正在播放</div>
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div> <div class="media-row">
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`; ${coverHtml}
<div class="media-info">
<div class="tt">${escapeHtml(info.title)}</div>
<div class="ar">${escapeHtml(info.artist)}</div>
</div>
</div>
<div class="prog"><div class="bar"></div></div>
<div class="controls">
<button data-act="prev" title="上一首">⏮</button>
<button data-act="play" title="播放/暂停">${info.playing ? '⏸' : '▶'}</button>
<button data-act="next" title="下一首">⏭</button>
</div>`;
// 绑定控制按钮(原生 transport controls
// Bind the controls (native transport controls)
el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious');
el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause');
el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext');
// 进度条实时更新
startMediaProgress(el, info);
// 多会话滑动切换
setupMediaSwipe(el);
return el; return el;
} }
// 进度条实时更新(播放时每秒推进)
// Live progress bar (advance every second while playing)
function startMediaProgress(el, info) {
const bar = el.querySelector('.bar');
const startTime = Date.now();
const startPos = info.position || 0;
const duration = info.duration || 0;
const tick = () => {
if (!duration) return;
const pos = info.playing ? startPos + (Date.now() - startTime) : startPos;
const pct = Math.min(100, (pos / duration) * 100);
bar.style.width = pct + '%';
};
tick();
el._progressTimer = setInterval(tick, 1000);
}
// 多会话滑动切换(左右滑动切换媒体会话)
// Multi-session swipe (swipe left/right to switch media sessions)
function setupMediaSwipe(el) {
let startX = 0;
el.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
}, { passive: true });
el.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - startX;
if (Math.abs(dx) < 60) return;
switchMedia(dx < 0 ? 1 : -1);
}, { passive: true });
}
// 切换当前媒体会话
// Switch the current media session
function switchMedia(delta) {
if (!currentMediaList || currentMediaList.length <= 1) return;
currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length;
const grid = document.getElementById('tri-col');
if (!grid) return;
const old = grid.querySelector('.media-card');
if (old) {
if (old._progressTimer) clearInterval(old._progressTimer);
old.replaceWith(renderMediaCard(currentMediaList[currentMediaIndex]));
}
}
// 转义 HTML 特殊字符,避免媒体元数据注入标签 // 转义 HTML 特殊字符,避免媒体元数据注入标签
// Escape HTML special chars to prevent metadata injection // Escape HTML special chars to prevent metadata injection
function escapeHtml(s) { function escapeHtml(s) {
@@ -1,11 +1,12 @@
package top.yeij.hearth.media 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( data class MediaInfo(
val title: String, val title: String,
val artist: String, val artist: String,
val album: String, val album: String,
val cover: String,
val playing: Boolean, val playing: Boolean,
val position: Long, val position: Long,
val duration: Long, val duration: Long,
@@ -2,14 +2,21 @@ package top.yeij.hearth.media
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.session.MediaController import android.media.session.MediaController
import android.media.session.MediaSessionManager import android.media.session.MediaSessionManager
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.util.Base64
import android.util.Log 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) { class MediaSessionSource(context: Context) {
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager 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. // 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)
private var callback: ((MediaInfo?) -> Unit)? = null private var callback: ((List<MediaInfo>) -> Unit)? = null
private var registered = false 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 = private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { controllers -> MediaSessionManager.OnActiveSessionsChangedListener { list ->
val cb = callback ?: return@OnActiveSessionsChangedListener if (callback == null) return@OnActiveSessionsChangedListener
notifyMedia(controllers?.firstOrNull(), cb) updateControllers(list?.toList() ?: emptyList())
pushCurrent()
} }
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。 // 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
// 注册后主动拉取一次当前活跃会话:注册前已开始的播放不会触发变化回调 // 注册后主动拉取一次当前会话:注册前已开始的播放不会触发变化回调
// 主动拉取保证启动时媒体卡即可显示。
// Register the listener: idempotent — remove the old one first to avoid accumulation. // Register the listener: idempotent — remove the old one first to avoid accumulation.
// After registering, actively pull the current sessions once: playback that started // After registering, actively pull the current sessions once.
// before registration never fires the change callback, so the active pull makes the fun start(cb: (List<MediaInfo>) -> Unit) {
// media card show immediately on startup.
fun start(cb: (MediaInfo?) -> Unit) {
stop() stop()
callback = cb callback = cb
try { try {
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent) msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true registered = true
Log.d(TAG, "start: media session listener registered") Log.d(TAG, "start: media session listener registered")
val current = msm.getActiveSessions(listenerComponent).firstOrNull() updateControllers(msm.getActiveSessions(listenerComponent))
notifyMedia(current, cb) pushCurrent()
} catch (e: SecurityException) { } catch (e: SecurityException) {
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。 // 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
// 用户在系统设置开启通知使用权后,重启 Hearth 才会恢复媒体卡。
// MediaSessionService throws SecurityException without notification access; // MediaSessionService throws SecurityException without notification access;
// degrade to media listening disabled instead of crashing. Media card recovers // degrade to media listening disabled instead of crashing.
// after the user grants notification access and restarts Hearth.
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}") Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
registered = false registered = false
} }
@@ -65,44 +83,92 @@ class MediaSessionSource(context: Context) {
registered = false registered = false
Log.d(TAG, "stop: media session listener removed") Log.d(TAG, "stop: media session listener removed")
} }
unregisterCallbacks()
callback = null callback = null
} }
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡 // 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
// Actively pull the current session and notify (without re-registering), for // Actively pull the current sessions and notify (without re-registering)
// refreshing the media card once the H5 page is ready
fun refresh() { fun refresh() {
val cb = callback ?: return if (callback == null) return
try { try {
val current = msm.getActiveSessions(listenerComponent).firstOrNull() updateControllers(msm.getActiveSessions(listenerComponent))
notifyMedia(current, cb) pushCurrent()
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.w(TAG, "refresh: no notification access: ${e.message}") Log.w(TAG, "refresh: no notification access: ${e.message}")
} }
} }
// 从 controller 提取 metadata + playbackState 构造 MediaInfo 并回调 // 播放控制:作用于第一个(当前展示)controller
// Build MediaInfo from the controller's metadata + playbackState and invoke the callback // Transport controls: act on the first (currently shown) controller
private fun notifyMedia(controller: MediaController?, cb: (MediaInfo?) -> Unit) { fun previous() {
if (controller == null) { controllers.firstOrNull()?.transportControls?.skipToPrevious()
Log.d(TAG, "no active media session") Log.d(TAG, "previous")
cb(null) }
return
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 列表给 H5
MediaInfo( // Push the current MediaInfo list for all sessions to H5
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "", private fun pushCurrent() {
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "", callback?.let { cb -> cb(controllers.map { toMediaInfo(it) }) }
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "", }
state?.state == PlaybackState.STATE_PLAYING,
state?.position ?: 0L, // 从 controller 提取 metadata + playbackState + 封面构造 MediaInfo
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L, // Build MediaInfo from the controller's metadata + playbackState + cover
controller.packageName, 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}") }
// 封面缩放到 128pxJPEG 压缩转 base64data 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 { companion object {
@@ -304,7 +304,7 @@ class JsBridge(
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) { fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
mediaSourceRef = source mediaSourceRef = source
source.start { info -> pushMedia(info, webView) } source.start { infos -> pushMedia(infos, webView) }
} }
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题 // H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
@@ -314,9 +314,26 @@ class JsBridge(
mediaSourceRef?.refresh() 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 { webView.post {
val json = info?.toJson() ?: "null" val json = gson.toJson(infos)
webView.evaluateJavascript( webView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", "window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
null null