feat: media card polish - per-index controls, cover jump, wrap, slide transition

This commit is contained in:
2026-08-16 20:13:47 +08:00
parent c82418f846
commit 8d10d9e5e9
4 changed files with 250 additions and 89 deletions
+114 -37
View File
@@ -143,6 +143,7 @@ body {
/* Cards stretch to fill the column height (even split when multiple) */ /* Cards stretch to fill the column height (even split when multiple) */
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
min-width: 0; /* 防止内容(长标题/歌词)撑开 Grid 列宽 */
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: var(--blur-filter); backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter); -webkit-backdrop-filter: var(--blur-filter);
@@ -151,6 +152,13 @@ body {
border-radius: 14px; border-radius: 14px;
padding: 20px; padding: 20px;
color: var(--text); 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); color: var(--text-dim);
} }
/* 媒体卡:flex column标题靠上、进度/控制靠下 */ /* 媒体卡:flex column信息区靠上、进度/控制靠下 */
/* Media card: flex column, title top, progress/controls bottom */ /* Media card: flex column, info top, progress/controls bottom */
.media-card { .media-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */ /* 媒体卡布局:默认上下(封面在上、信息在下),横条时左右(.wide) */
/* Media card: playing caption + title/artist + progress bar */ /* Media layout: vertical (cover on top, info below) by default; horizontal
.media-card .cap { (cover left, info right) when the card is wider than tall (.wide) */
font-size: 11px; .media-card .media-layout {
color: var(--text-dim); display: flex;
letter-spacing: 1px; 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 { .media-card .tt {
margin-top: 6px; margin-top: 6px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
white-space: normal;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.media-card .ar { .media-card .ar {
@@ -221,27 +280,8 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* 媒体卡:封面 + 信息横排 */ /* 进度条 */
/* Media card: cover + info row */ /* Progress bar */
.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;
@@ -255,34 +295,71 @@ body {
height: 100%; height: 100%;
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
transition: width .3s linear;
} }
/* 媒体控制按钮:上一首/暂停/下一首 */ /* 控制按钮:SVG 图标 + 按压动画 */
/* Media controls: previous / play-pause / next */ /* Controls: SVG icons + press animation */
.media-card .controls { .media-card .controls {
margin-top: 12px; margin-top: 12px;
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: center;
} }
.media-card .controls button { .media-card .controls button {
width: 36px; width: 40px;
height: 36px; height: 40px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background: var(--glass-border); background: var(--glass-border);
color: var(--text); color: var(--text);
font-size: 15px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .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 列表页:搜索框 + 网格 */ /* 安卓 APP 列表页:搜索框 + 网格 */
/* Android app list page: search box + grid */ /* Android app list page: search box + grid */
.search { padding: 16px; } .search { padding: 16px; }
+79 -31
View File
@@ -103,6 +103,13 @@ function startTimeCardTicker() {
let currentMediaList = null; let currentMediaList = null;
let currentMediaIndex = 0; let currentMediaIndex = 0;
// SVG 控制图标(fill=currentColor,替代 emoji
// SVG control icons (fill=currentColor, replacing emoji)
const ICON_PREV = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6l-8 6 8 6V6z"/></svg>';
const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7L8 5z"/></svg>';
const ICON_PAUSE = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>';
const ICON_NEXT = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M4 6l8 6-8 6V6z"/></svg>';
// 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级 // 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
// Native media session event: receives the session list; render media card when // Native media session event: receives the session list; render media card when
// present, otherwise fall back to re-layout // present, otherwise fall back to re-layout
@@ -111,9 +118,11 @@ window.HearthEvents.mediaSessionChanged = function (infos) {
if (!infos || infos.length === 0) { if (!infos || infos.length === 0) {
currentMediaList = null; currentMediaList = null;
currentMediaIndex = 0; currentMediaIndex = 0;
mediaProgressState = null;
} else { } else {
currentMediaList = infos; currentMediaList = infos;
if (currentMediaIndex >= infos.length) currentMediaIndex = 0; if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
updateMediaProgressState(infos[currentMediaIndex]);
} }
const grid = document.getElementById('tri-col'); const grid = document.getElementById('tri-col');
if (!grid) return; if (!grid) return;
@@ -127,11 +136,12 @@ function renderMediaCard(info) {
el.className = 'card media-card'; el.className = 'card media-card';
const coverHtml = info.cover const coverHtml = info.cover
? `<img class="cover" src="${info.cover}" alt="">` ? `<img class="cover" src="${info.cover}" alt="">`
: ''; : '<div class="cover cover-ph"></div>';
const playIcon = info.playing ? ICON_PAUSE : ICON_PLAY;
// 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="media-row"> <div class="media-layout">
${coverHtml} ${coverHtml}
<div class="media-info"> <div class="media-info">
<div class="tt">${escapeHtml(info.title)}</div> <div class="tt">${escapeHtml(info.title)}</div>
@@ -140,37 +150,65 @@ function renderMediaCard(info) {
</div> </div>
<div class="prog"><div class="bar"></div></div> <div class="prog"><div class="bar"></div></div>
<div class="controls"> <div class="controls">
<button data-act="prev" title="上一首"></button> <button class="ctl" data-act="prev" title="上一首">${ICON_PREV}</button>
<button data-act="play" title="播放/暂停">${info.playing ? '⏸' : '▶'}</button> <button class="ctl play" data-act="play" title="播放/暂停">${playIcon}</button>
<button data-act="next" title="下一首"></button> <button class="ctl" data-act="next" title="下一首">${ICON_NEXT}</button>
</div>`; </div>`;
// 绑定控制按钮(原生 transport controls // 绑定控制按钮(传入当前索引,作用于当前滑到的会话
// Bind the controls (native transport controls) // Bind the controls (pass the current index, act on the shown session)
el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious'); el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious', currentMediaIndex);
el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause'); el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause', currentMediaIndex);
el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext'); el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext', currentMediaIndex);
// 进度条实时更新 // 封面点击跳转到对应媒体 App 的播放界面
startMediaProgress(el, info); // 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); setupMediaSwipe(el);
// 布局方向(上下 / 左右)
applyMediaLayout(el);
return el; return el;
} }
// 进度条实时更新(播放时每秒推进) // 全局进度状态:独立于卡片重建,进度条不因重新渲染而"跳回"
// Live progress bar (advance every second while playing) // Global progress state: independent of card rebuild, so the bar doesn't reset on re-render
function startMediaProgress(el, info) { let mediaProgressState = null;
const bar = el.querySelector('.bar'); function updateMediaProgressState(info) {
const startTime = Date.now(); mediaProgressState = {
const startPos = info.position || 0; position: info.position || 0,
const duration = info.duration || 0; duration: info.duration || 0,
const tick = () => { playing: !!info.playing,
if (!duration) return; updatedAt: Date.now(),
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);
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) { function switchMedia(delta) {
if (!currentMediaList || currentMediaList.length <= 1) return; if (!currentMediaList || currentMediaList.length <= 1) return;
currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length; currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length;
const info = currentMediaList[currentMediaIndex];
updateMediaProgressState(info);
const grid = document.getElementById('tri-col'); const grid = document.getElementById('tri-col');
if (!grid) return; if (!grid) return;
const old = grid.querySelector('.media-card'); const old = grid.querySelector('.media-card');
if (old) { if (!old) return;
if (old._progressTimer) clearInterval(old._progressTimer); // 滑动方向过渡:左滑(下一个)旧卡左出、新卡右入;右滑反之
old.replaceWith(renderMediaCard(currentMediaList[currentMediaIndex])); // 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 特殊字符,避免媒体元数据注入标签 // 转义 HTML 特殊字符,避免媒体元数据注入标签
@@ -2,6 +2,7 @@ package top.yeij.hearth.media
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.session.MediaController import android.media.session.MediaController
@@ -13,11 +14,13 @@ import java.io.ByteArrayOutputStream
// 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线), // 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
// 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送); // 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
// 提供播放控制(上一首/暂停播放/下一首)封面提取。 // 提供播放控制(上一首/暂停播放/下一首)封面提取与跳转播放界面
// Watch active media sessions (supports multiple: music/audiobook/video at once), // Watch active media sessions (supports multiple: music/audiobook/video at once),
// push in real time on metadata/playback-state change (track switch, play/pause); // 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) { class MediaSessionSource(context: Context) {
private val appContext = context.applicationContext
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
// 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener // 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
@@ -99,23 +102,48 @@ class MediaSessionSource(context: Context) {
} }
} }
// 播放控制:作用于第一个(当前展示)controller // 播放控制:作用于指定索引的 controller(对应 H5 当前滑到的会话)
// Transport controls: act on the first (currently shown) controller // Transport controls: act on the controller at the given index (matching the
fun previous() { // session the H5 card is currently showing)
controllers.firstOrNull()?.transportControls?.skipToPrevious() fun previous(index: Int) {
Log.d(TAG, "previous") controllers.getOrNull(index)?.transportControls?.skipToPrevious()
Log.d(TAG, "previous: index=$index")
} }
fun next() { fun next(index: Int) {
controllers.firstOrNull()?.transportControls?.skipToNext() controllers.getOrNull(index)?.transportControls?.skipToNext()
Log.d(TAG, "next") Log.d(TAG, "next: index=$index")
} }
fun playPause() { fun playPause(index: Int) {
val c = controllers.firstOrNull() ?: return val c = controllers.getOrNull(index) ?: return
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
if (playing) c.transportControls.pause() else c.transportControls.play() if (playing) c.transportControls.pause() else c.transportControls.play()
Log.d(TAG, "playPause: playing=$playing") Log.d(TAG, "playPause: index=$index playing=$playing")
}
// 跳转到指定会话的播放界面:优先 sessionActivityPendingIntent),
// 失败则回退到启动该 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 列表:注销旧回调、注册新回调 // 更新 controller 列表:注销旧回调、注册新回调
@@ -314,21 +314,29 @@ class JsBridge(
mediaSourceRef?.refresh() mediaSourceRef?.refresh()
} }
// 媒体播放控制(作用于当前展示的会话 // 媒体播放控制(作用于指定索引的会话,对应 H5 当前滑到的卡片
// Media transport controls (act on the currently shown session) // Media transport controls (act on the session at the given index, matching
// the card the H5 is currently showing)
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun mediaPrevious() { fun mediaPrevious(index: Int) {
mediaSourceRef?.previous() mediaSourceRef?.previous(index)
} }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun mediaPlayPause() { fun mediaPlayPause(index: Int) {
mediaSourceRef?.playPause() mediaSourceRef?.playPause(index)
} }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun mediaNext() { fun mediaNext(index: Int) {
mediaSourceRef?.next() 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<MediaInfo>, webView: android.webkit.WebView) { private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {