diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css
index bc604bc..ed69d39 100644
--- a/app/src/main/assets/h5/css/app.css
+++ b/app/src/main/assets/h5/css/app.css
@@ -167,6 +167,10 @@ body {
font-size: 56px;
font-weight: 300;
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;
}
+/* 媒体卡:封面 + 信息横排 */
+/* 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 {
margin-top: auto;
padding-top: 12px;
diff --git a/app/src/main/assets/h5/js/pages/home.js b/app/src/main/assets/h5/js/pages/home.js
index 99d07a5..c3b3038 100644
--- a/app/src/main/assets/h5/js/pages/home.js
+++ b/app/src/main/assets/h5/js/pages/home.js
@@ -36,7 +36,9 @@ function renderCards(catalog) {
// time 卡(内置,span 1)
if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() });
// 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]) });
+ }
// 其他卡(排除 time)span 1
sorted.filter(c => c.id !== 'time').forEach(c => {
items.push({ span: 1, el: renderGenericCard(c.id) });
@@ -96,37 +98,109 @@ function startTimeCardTicker() {
}, 1000);
}
-// 当前媒体信息(null 表示无媒体);切页后 updateHomeCards 据此恢复媒体卡
-// Current media info (null = none); updateHomeCards restores the media card from it
-let currentMedia = null;
+// 当前媒体会话列表(多个:音乐/听书/视频)+ 当前展示索引
+// Current media session list (multiple: music/audiobook/video) + shown index
+let currentMediaList = null;
+let currentMediaIndex = 0;
-// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局
-// 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)
+// 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
+// Native media session event: receives the session list; render media card when
+// present, otherwise fall back to re-layout
window.HearthEvents = window.HearthEvents || {};
-window.HearthEvents.mediaSessionChanged = function (info) {
- currentMedia = info;
+window.HearthEvents.mediaSessionChanged = function (infos) {
+ if (!infos || infos.length === 0) {
+ currentMediaList = null;
+ currentMediaIndex = 0;
+ } else {
+ currentMediaList = infos;
+ if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
+ }
const grid = document.getElementById('tri-col');
if (!grid) return;
- // 重新渲染:有媒体时 media 卡 span 2,无媒体时其他卡填满
- // Re-render: media card spans 2 when present, others fill otherwise
window.updateHomeCards();
};
-// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条
-// Media card: playing caption + title/artist + progress bar
+// 媒体卡:封面 + 标题/艺术家 + 进度条 + 控制按钮 + 多会话滑动切换
+// Media card: cover + title/artist + progress + controls + multi-session swipe
function renderMediaCard(info) {
const el = document.createElement('div');
el.className = 'card media-card';
- const pct = info.duration ? (info.position / info.duration) * 100 : 0;
+ const coverHtml = info.cover
+ ? `
`
+ : '';
// title/artist 来自任意应用元数据,转义后插入,防 XSS
// title/artist come from arbitrary app metadata; escape before insert (XSS)
el.innerHTML = `
正在播放
- ${escapeHtml(info.title)}
${escapeHtml(info.artist)}
- `;
+
+
+
+
+
+
+
`;
+ // 绑定控制按钮(原生 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;
}
+// 进度条实时更新(播放时每秒推进)
+// 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 特殊字符,避免媒体元数据注入标签
// Escape HTML special chars to prevent metadata injection
function escapeHtml(s) {
diff --git a/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt b/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt
index a7c3224..58454f9 100644
--- a/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt
+++ b/app/src/main/java/top/yeij/hearth/media/MediaInfo.kt
@@ -1,11 +1,12 @@
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(
val title: String,
val artist: String,
val album: String,
+ val cover: String,
val playing: Boolean,
val position: Long,
val duration: Long,
diff --git a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
index d7b242c..2de23c9 100644
--- a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
+++ b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
@@ -2,14 +2,21 @@ package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context
+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.util.Base64
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) {
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.
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
- private var callback: ((MediaInfo?) -> Unit)? = null
+ private var callback: ((List) -> Unit)? = null
private var registered = false
+ private val controllers = mutableListOf()
- // 具名监听器字段:会话列表变化时回调
- // 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 =
- MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
- val cb = callback ?: return@OnActiveSessionsChangedListener
- notifyMedia(controllers?.firstOrNull(), cb)
+ 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: playback that started
- // before registration never fires the change callback, so the active pull makes the
- // media card show immediately on startup.
- fun start(cb: (MediaInfo?) -> Unit) {
+ // 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")
- val current = msm.getActiveSessions(listenerComponent).firstOrNull()
- notifyMedia(current, cb)
+ updateControllers(msm.getActiveSessions(listenerComponent))
+ pushCurrent()
} catch (e: SecurityException) {
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
- // 用户在系统设置开启通知使用权后,重启 Hearth 才会恢复媒体卡。
// MediaSessionService throws SecurityException without notification access;
- // degrade to media listening disabled instead of crashing. Media card recovers
- // after the user grants notification access and restarts Hearth.
+ // degrade to media listening disabled instead of crashing.
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
registered = false
}
@@ -65,44 +83,92 @@ class MediaSessionSource(context: Context) {
registered = false
Log.d(TAG, "stop: media session listener removed")
}
+ unregisterCallbacks()
callback = null
}
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
- // Actively pull the current session and notify (without re-registering), for
- // refreshing the media card once the H5 page is ready
+ // Actively pull the current sessions and notify (without re-registering)
fun refresh() {
- val cb = callback ?: return
+ if (callback == null) return
try {
- val current = msm.getActiveSessions(listenerComponent).firstOrNull()
- notifyMedia(current, cb)
+ updateControllers(msm.getActiveSessions(listenerComponent))
+ pushCurrent()
} catch (e: SecurityException) {
Log.w(TAG, "refresh: no notification access: ${e.message}")
}
}
- // 从 controller 提取 metadata + playbackState 构造 MediaInfo 并回调
- // Build MediaInfo from the controller's metadata + playbackState and invoke the callback
- private fun notifyMedia(controller: MediaController?, cb: (MediaInfo?) -> Unit) {
- if (controller == null) {
- Log.d(TAG, "no active media session")
- cb(null)
- return
+ // 播放控制:作用于第一个(当前展示)controller
+ // Transport controls: act on the first (currently shown) controller
+ fun previous() {
+ controllers.firstOrNull()?.transportControls?.skipToPrevious()
+ Log.d(TAG, "previous")
+ }
+
+ 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) {
+ 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(
- 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,
- controller.packageName,
- )
+ }
+
+ // 推送当前全部会话的 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,
)
- Log.d(TAG, "media: ${controller.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 {
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 e5158e3..aecc2ea 100644
--- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt
+++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt
@@ -304,7 +304,7 @@ class JsBridge(
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
mediaSourceRef = source
- source.start { info -> pushMedia(info, webView) }
+ source.start { infos -> pushMedia(infos, webView) }
}
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
@@ -314,9 +314,26 @@ class JsBridge(
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, webView: android.webkit.WebView) {
webView.post {
- val json = info?.toJson() ?: "null"
+ val json = gson.toJson(infos)
webView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
null