From 8d10d9e5e920c90957ec17288ccf8bf7b2c9d9ef Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 16 Aug 2026 20:13:47 +0800 Subject: [PATCH] feat: media card polish - per-index controls, cover jump, wrap, slide transition --- app/src/main/assets/h5/css/app.css | 151 +++++++++++++----- app/src/main/assets/h5/js/pages/home.js | 110 +++++++++---- .../yeij/hearth/media/MediaSessionSource.kt | 54 +++++-- .../java/top/yeij/hearth/webview/JsBridge.kt | 24 ++- 4 files changed, 250 insertions(+), 89 deletions(-) diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css index ed69d39..c82b266 100644 --- a/app/src/main/assets/h5/css/app.css +++ b/app/src/main/assets/h5/css/app.css @@ -143,6 +143,7 @@ body { /* Cards stretch to fill the column height (even split when multiple) */ flex: 1 1 0; min-height: 0; + min-width: 0; /* 防止内容(长标题/歌词)撑开 Grid 列宽 */ background: var(--glass-bg); backdrop-filter: var(--blur-filter); -webkit-backdrop-filter: var(--blur-filter); @@ -151,6 +152,13 @@ body { border-radius: 14px; padding: 20px; color: var(--text); + transition: transform .18s ease; +} + +/* 卡片非交互区域的触摸按压反馈:轻微缩放,避免生硬 */ +/* Touch/press feedback on the card's non-interactive area: subtle scale */ +.card:active { + transform: scale(0.985); } /* 大字时间卡:主时间大字 + 日期副行 */ @@ -188,28 +196,79 @@ body { color: var(--text-dim); } -/* 媒体卡:flex column,标题靠上、进度/控制靠下 */ -/* Media card: flex column, title top, progress/controls bottom */ +/* 媒体卡:flex column,信息区靠上、进度/控制靠下 */ +/* Media card: flex column, info top, progress/controls bottom */ .media-card { display: flex; flex-direction: column; + overflow: hidden; } -/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */ -/* Media card: playing caption + title/artist + progress bar */ -.media-card .cap { - font-size: 11px; - color: var(--text-dim); - letter-spacing: 1px; +/* 媒体卡布局:默认上下(封面在上、信息在下),横条时左右(.wide) */ +/* Media layout: vertical (cover on top, info below) by default; horizontal + (cover left, info right) when the card is wider than tall (.wide) */ +.media-card .media-layout { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + flex: 1; + min-height: 0; + text-align: center; } +.media-card.wide .media-layout { + flex-direction: row; + text-align: left; +} + +/* 封面:自适应,随卡片变大;可点击跳转播放界面 */ +/* Cover: adaptive size, grows with the card; tappable to open the playback UI */ +.media-card .cover { + width: 96px; + height: 96px; + max-width: 100%; + border-radius: 16px; + object-fit: cover; + flex: none; + cursor: pointer; + transition: transform .18s ease; +} + +.media-card .cover:active { + transform: scale(0.92); +} + +.media-card .cover-ph { + background: linear-gradient(135deg, rgba(255, 255, 255, .15), rgba(255, 255, 255, .05)); +} + +.media-card.wide .cover { + width: 120px; + height: 120px; +} + +/* 信息区 */ +/* Info area */ +.media-card .media-info { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* 标题:自动换行(歌词/长标题),最多 3 行省略 */ +/* Title: wraps automatically (lyrics / long titles), max 3 lines with ellipsis */ .media-card .tt { margin-top: 6px; font-size: 18px; font-weight: 600; + white-space: normal; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .media-card .ar { @@ -221,27 +280,8 @@ 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; -} - +/* 进度条 */ +/* Progress bar */ .media-card .prog { margin-top: auto; padding-top: 12px; @@ -255,34 +295,71 @@ body { height: 100%; border-radius: 999px; background: var(--accent); + transition: width .3s linear; } -/* 媒体控制按钮:上一首/暂停/下一首 */ -/* Media controls: previous / play-pause / next */ +/* 控制按钮:SVG 图标 + 按压动画 */ +/* Controls: SVG icons + press animation */ .media-card .controls { margin-top: 12px; display: flex; gap: 10px; + justify-content: center; } .media-card .controls button { - width: 36px; - height: 36px; + width: 40px; + height: 40px; border: none; border-radius: 999px; background: var(--glass-border); color: var(--text); - font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: transform .15s ease, background .15s ease, opacity .15s ease; +} + +.media-card .controls button svg { + width: 20px; + height: 20px; +} + +.media-card .controls button.play { + width: 48px; + height: 48px; + background: var(--accent); + color: #fff; } .media-card .controls button:active { - opacity: 0.6; + transform: scale(0.82); + opacity: 0.7; } +/* 媒体卡横划切换的方向过渡动画 */ +/* Directional slide transitions for the media card swipe */ +@keyframes slide-out-left { + to { transform: translateX(-40%); opacity: 0; } +} +@keyframes slide-out-right { + to { transform: translateX(40%); opacity: 0; } +} +@keyframes slide-in-left { + from { transform: translateX(-40%); opacity: 0; } + to { transform: none; opacity: 1; } +} +@keyframes slide-in-right { + from { transform: translateX(40%); opacity: 0; } + to { transform: none; opacity: 1; } +} + +.media-card.slide-out-left { animation: slide-out-left .2s ease forwards; } +.media-card.slide-out-right { animation: slide-out-right .2s ease forwards; } +.media-card.slide-in-left { animation: slide-in-left .25s ease; } +.media-card.slide-in-right { animation: slide-in-right .25s ease; } + /* 安卓 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 c3b3038..4070967 100644 --- a/app/src/main/assets/h5/js/pages/home.js +++ b/app/src/main/assets/h5/js/pages/home.js @@ -103,6 +103,13 @@ function startTimeCardTicker() { let currentMediaList = null; let currentMediaIndex = 0; +// SVG 控制图标(fill=currentColor,替代 emoji) +// SVG control icons (fill=currentColor, replacing emoji) +const ICON_PREV = ''; +const ICON_PLAY = ''; +const ICON_PAUSE = ''; +const ICON_NEXT = ''; + // 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级 // Native media session event: receives the session list; render media card when // present, otherwise fall back to re-layout @@ -111,9 +118,11 @@ window.HearthEvents.mediaSessionChanged = function (infos) { if (!infos || infos.length === 0) { currentMediaList = null; currentMediaIndex = 0; + mediaProgressState = null; } else { currentMediaList = infos; if (currentMediaIndex >= infos.length) currentMediaIndex = 0; + updateMediaProgressState(infos[currentMediaIndex]); } const grid = document.getElementById('tri-col'); if (!grid) return; @@ -127,11 +136,12 @@ function renderMediaCard(info) { el.className = 'card media-card'; const coverHtml = info.cover ? `` - : ''; + : '
'; + const playIcon = info.playing ? ICON_PAUSE : ICON_PLAY; // title/artist 来自任意应用元数据,转义后插入,防 XSS // title/artist come from arbitrary app metadata; escape before insert (XSS) - el.innerHTML = `
正在播放
-
+ el.innerHTML = ` +
${coverHtml}
${escapeHtml(info.title)}
@@ -140,37 +150,65 @@ function renderMediaCard(info) {
- - - + + +
`; - // 绑定控制按钮(原生 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); + // 绑定控制按钮(传入当前索引,作用于当前滑到的会话) + // Bind the controls (pass the current index, act on the shown session) + el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious', currentMediaIndex); + el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause', currentMediaIndex); + el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext', currentMediaIndex); + // 封面点击跳转到对应媒体 App 的播放界面 + // Cover tap opens the media app's playback UI + const coverEl = el.querySelector('.cover'); + if (coverEl) coverEl.onclick = () => bridge.call('openMediaApp', currentMediaIndex); + // 进度状态 + 全局 ticker + updateMediaProgressState(info); + startMediaTicker(); // 多会话滑动切换 setupMediaSwipe(el); + // 布局方向(上下 / 左右) + applyMediaLayout(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 + '%'; +// 全局进度状态:独立于卡片重建,进度条不因重新渲染而"跳回" +// Global progress state: independent of card rebuild, so the bar doesn't reset on re-render +let mediaProgressState = null; +function updateMediaProgressState(info) { + mediaProgressState = { + position: info.position || 0, + duration: info.duration || 0, + playing: !!info.playing, + updatedAt: Date.now(), }; - tick(); - el._progressTimer = setInterval(tick, 1000); +} + +let mediaTicker = null; +function startMediaTicker() { + if (mediaTicker) return; + mediaTicker = setInterval(() => { + const bar = document.querySelector('.media-card .bar'); + if (!bar || !mediaProgressState) return; + const { position, duration, playing, updatedAt } = mediaProgressState; + if (!duration) return; + const pos = playing ? position + (Date.now() - updatedAt) : position; + bar.style.width = Math.min(100, (pos / duration) * 100) + '%'; + }, 1000); +} + +// 布局方向:默认上下(封面在上、信息在下),横条(宽>高)时左右 +// Layout direction: vertical (cover on top, info below) by default; horizontal +// (cover left, info right) when the card is wider than tall +function applyMediaLayout(el) { + const check = () => { + const w = el.clientWidth; + const h = el.clientHeight; + if (w > 0 && h > 0) el.classList.toggle('wide', w > h); + }; + check(); + requestAnimationFrame(check); } // 多会话滑动切换(左右滑动切换媒体会话) @@ -192,13 +230,23 @@ function setupMediaSwipe(el) { function switchMedia(delta) { if (!currentMediaList || currentMediaList.length <= 1) return; currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length; + const info = currentMediaList[currentMediaIndex]; + updateMediaProgressState(info); 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])); - } + if (!old) return; + // 滑动方向过渡:左滑(下一个)旧卡左出、新卡右入;右滑反之 + // Directional transition: swipe left (next) slides old out left and new in from + // the right; swipe right (prev) reverses it + const outClass = delta > 0 ? 'slide-out-left' : 'slide-out-right'; + const inClass = delta > 0 ? 'slide-in-right' : 'slide-in-left'; + old.classList.add(outClass); + old.addEventListener('animationend', () => { + const fresh = renderMediaCard(info); + fresh.classList.add(inClass); + old.replaceWith(fresh); + }, { once: true }); } // 转义 HTML 特殊字符,避免媒体元数据注入标签 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 2de23c9..25bd30a 100644 --- a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt +++ b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt @@ -2,6 +2,7 @@ package top.yeij.hearth.media import android.content.ComponentName import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.media.MediaMetadata import android.media.session.MediaController @@ -13,11 +14,13 @@ import java.io.ByteArrayOutputStream // 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线), // 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送); -// 提供播放控制(上一首/暂停播放/下一首)与封面提取。 +// 提供播放控制(上一首/暂停播放/下一首)、封面提取与跳转播放界面。 // 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. +// provide transport controls (previous/play-pause/next), cover extraction and +// jumping to the playback UI. class MediaSessionSource(context: Context) { + private val appContext = context.applicationContext private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager // 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener, @@ -99,23 +102,48 @@ class MediaSessionSource(context: Context) { } } - // 播放控制:作用于第一个(当前展示)controller - // Transport controls: act on the first (currently shown) controller - fun previous() { - controllers.firstOrNull()?.transportControls?.skipToPrevious() - Log.d(TAG, "previous") + // 播放控制:作用于指定索引的 controller(对应 H5 当前滑到的会话) + // Transport controls: act on the controller at the given index (matching the + // session the H5 card is currently showing) + fun previous(index: Int) { + controllers.getOrNull(index)?.transportControls?.skipToPrevious() + Log.d(TAG, "previous: index=$index") } - fun next() { - controllers.firstOrNull()?.transportControls?.skipToNext() - Log.d(TAG, "next") + fun next(index: Int) { + controllers.getOrNull(index)?.transportControls?.skipToNext() + Log.d(TAG, "next: index=$index") } - fun playPause() { - val c = controllers.firstOrNull() ?: return + fun playPause(index: Int) { + val c = controllers.getOrNull(index) ?: return val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING if (playing) c.transportControls.pause() else c.transportControls.play() - Log.d(TAG, "playPause: playing=$playing") + Log.d(TAG, "playPause: index=$index playing=$playing") + } + + // 跳转到指定会话的播放界面:优先 sessionActivity(PendingIntent), + // 失败则回退到启动该 App + // Jump to the session's playback UI: prefer sessionActivity (PendingIntent), + // fall back to launching the app + fun openMediaApp(index: Int) { + val c = controllers.getOrNull(index) ?: return + val sessionActivity = c.sessionActivity + if (sessionActivity != null) { + try { + sessionActivity.send() + Log.d(TAG, "openMediaApp: sessionActivity sent for ${c.packageName}") + return + } catch (e: Exception) { + Log.w(TAG, "openMediaApp: sessionActivity failed: ${e.message}") + } + } + val launch = appContext.packageManager.getLaunchIntentForPackage(c.packageName) + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(launch) + Log.d(TAG, "openMediaApp: launched ${c.packageName}") + } } // 更新 controller 列表:注销旧回调、注册新回调 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 aecc2ea..32e29ae 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -314,21 +314,29 @@ class JsBridge( mediaSourceRef?.refresh() } - // 媒体播放控制(作用于当前展示的会话) - // Media transport controls (act on the currently shown session) + // 媒体播放控制(作用于指定索引的会话,对应 H5 当前滑到的卡片) + // Media transport controls (act on the session at the given index, matching + // the card the H5 is currently showing) @android.webkit.JavascriptInterface - fun mediaPrevious() { - mediaSourceRef?.previous() + fun mediaPrevious(index: Int) { + mediaSourceRef?.previous(index) } @android.webkit.JavascriptInterface - fun mediaPlayPause() { - mediaSourceRef?.playPause() + fun mediaPlayPause(index: Int) { + mediaSourceRef?.playPause(index) } @android.webkit.JavascriptInterface - fun mediaNext() { - mediaSourceRef?.next() + fun mediaNext(index: Int) { + mediaSourceRef?.next(index) + } + + // 跳转到指定会话的播放界面(封面点击) + // Jump to the session's playback UI (cover tap) + @android.webkit.JavascriptInterface + fun openMediaApp(index: Int) { + mediaSourceRef?.openMediaApp(index) } private fun pushMedia(infos: List, webView: android.webkit.WebView) {