feat: media card polish - per-index controls, cover jump, wrap, slide transition
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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 特殊字符,避免媒体元数据注入标签
|
||||
|
||||
@@ -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 列表:注销旧回调、注册新回调
|
||||
|
||||
@@ -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<MediaInfo>, webView: android.webkit.WebView) {
|
||||
|
||||
Reference in New Issue
Block a user