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)}
-
`; +
+ ${coverHtml} +
+
${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