feat: media card cover, controls, live progress, multi-session swipe
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? `<img class="cover" src="${info.cover}" alt="">`
|
||||
: '';
|
||||
// title/artist 来自任意应用元数据,转义后插入,防 XSS
|
||||
// title/artist come from arbitrary app metadata; escape before insert (XSS)
|
||||
el.innerHTML = `<div class="cap">正在播放</div>
|
||||
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div>
|
||||
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`;
|
||||
<div class="media-row">
|
||||
${coverHtml}
|
||||
<div class="media-info">
|
||||
<div class="tt">${escapeHtml(info.title)}</div>
|
||||
<div class="ar">${escapeHtml(info.artist)}</div>
|
||||
</div>
|
||||
</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>
|
||||
</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);
|
||||
// 多会话滑动切换
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user