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) */
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; }
+79 -31
View File
@@ -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 = '<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
// 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
? `<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 come from arbitrary app metadata; escape before insert (XSS)
el.innerHTML = `<div class="cap">正在播放</div>
<div class="media-row">
el.innerHTML = `
<div class="media-layout">
${coverHtml}
<div class="media-info">
<div class="tt">${escapeHtml(info.title)}</div>
@@ -140,37 +150,65 @@ function renderMediaCard(info) {
</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>
<button class="ctl" data-act="prev" title="上一首">${ICON_PREV}</button>
<button class="ctl play" data-act="play" title="播放/暂停">${playIcon}</button>
<button class="ctl" data-act="next" title="下一首">${ICON_NEXT}</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);
// 绑定控制按钮(传入当前索引,作用于当前滑到的会话
// 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 特殊字符,避免媒体元数据注入标签