feat: media card cover, controls, live progress, multi-session swipe
This commit is contained in:
@@ -167,6 +167,10 @@ body {
|
|||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
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;
|
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 {
|
.media-card .prog {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ function renderCards(catalog) {
|
|||||||
// time 卡(内置,span 1)
|
// time 卡(内置,span 1)
|
||||||
if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() });
|
if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() });
|
||||||
// media 卡(有媒体时 span 2,跨栏更美观)
|
// 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
|
// 其他卡(排除 time)span 1
|
||||||
sorted.filter(c => c.id !== 'time').forEach(c => {
|
sorted.filter(c => c.id !== 'time').forEach(c => {
|
||||||
items.push({ span: 1, el: renderGenericCard(c.id) });
|
items.push({ span: 1, el: renderGenericCard(c.id) });
|
||||||
@@ -96,37 +98,109 @@ function startTimeCardTicker() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前媒体信息(null 表示无媒体);切页后 updateHomeCards 据此恢复媒体卡
|
// 当前媒体会话列表(多个:音乐/听书/视频)+ 当前展示索引
|
||||||
// Current media info (null = none); updateHomeCards restores the media card from it
|
// Current media session list (multiple: music/audiobook/video) + shown index
|
||||||
let currentMedia = null;
|
let currentMediaList = null;
|
||||||
|
let currentMediaIndex = 0;
|
||||||
|
|
||||||
// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局
|
// 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
|
||||||
// Native media session event: render media card in middle column when present,
|
// Native media session event: receives the session list; render media card when
|
||||||
// otherwise fall back to re-layout (calendar/weather fill the media card slot)
|
// present, otherwise fall back to re-layout
|
||||||
window.HearthEvents = window.HearthEvents || {};
|
window.HearthEvents = window.HearthEvents || {};
|
||||||
window.HearthEvents.mediaSessionChanged = function (info) {
|
window.HearthEvents.mediaSessionChanged = function (infos) {
|
||||||
currentMedia = info;
|
if (!infos || infos.length === 0) {
|
||||||
|
currentMediaList = null;
|
||||||
|
currentMediaIndex = 0;
|
||||||
|
} else {
|
||||||
|
currentMediaList = infos;
|
||||||
|
if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
|
||||||
|
}
|
||||||
const grid = document.getElementById('tri-col');
|
const grid = document.getElementById('tri-col');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
// 重新渲染:有媒体时 media 卡 span 2,无媒体时其他卡填满
|
|
||||||
// Re-render: media card spans 2 when present, others fill otherwise
|
|
||||||
window.updateHomeCards();
|
window.updateHomeCards();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条
|
// 媒体卡:封面 + 标题/艺术家 + 进度条 + 控制按钮 + 多会话滑动切换
|
||||||
// Media card: playing caption + title/artist + progress bar
|
// Media card: cover + title/artist + progress + controls + multi-session swipe
|
||||||
function renderMediaCard(info) {
|
function renderMediaCard(info) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'card media-card';
|
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 来自任意应用元数据,转义后插入,防 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="cap">正在播放</div>
|
||||||
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div>
|
<div class="media-row">
|
||||||
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`;
|
${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;
|
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 特殊字符,避免媒体元数据注入标签
|
// 转义 HTML 特殊字符,避免媒体元数据注入标签
|
||||||
// Escape HTML special chars to prevent metadata injection
|
// Escape HTML special chars to prevent metadata injection
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package top.yeij.hearth.media
|
package top.yeij.hearth.media
|
||||||
|
|
||||||
// 媒体会话快照:标题/艺术家/专辑 + 播放状态/进度 + 来源包名
|
// 媒体会话快照:标题/艺术家/专辑 + 封面 + 播放状态/进度 + 来源包名
|
||||||
// Media session snapshot: title/artist/album + playback state/progress + source package
|
// Media session snapshot: title/artist/album + cover + playback state/progress + source package
|
||||||
data class MediaInfo(
|
data class MediaInfo(
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
val album: String,
|
val album: String,
|
||||||
|
val cover: String,
|
||||||
val playing: Boolean,
|
val playing: Boolean,
|
||||||
val position: Long,
|
val position: Long,
|
||||||
val duration: Long,
|
val duration: Long,
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ package top.yeij.hearth.media
|
|||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.session.MediaController
|
import android.media.session.MediaController
|
||||||
import android.media.session.MediaSessionManager
|
import android.media.session.MediaSessionManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
// 监听系统活跃媒体会话,取第一个 controller 的 metadata + playbackState 构造 MediaInfo 回调
|
// 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
|
||||||
// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState
|
// 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
|
||||||
|
// 提供播放控制(上一首/暂停播放/下一首)与封面提取。
|
||||||
|
// 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.
|
||||||
class MediaSessionSource(context: Context) {
|
class MediaSessionSource(context: Context) {
|
||||||
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
|
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
|
||||||
|
|
||||||
@@ -19,39 +26,50 @@ class MediaSessionSource(context: Context) {
|
|||||||
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
|
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
|
||||||
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
|
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
|
||||||
|
|
||||||
private var callback: ((MediaInfo?) -> Unit)? = null
|
private var callback: ((List<MediaInfo>) -> Unit)? = null
|
||||||
private var registered = false
|
private var registered = false
|
||||||
|
private val controllers = mutableListOf<MediaController>()
|
||||||
|
|
||||||
// 具名监听器字段:会话列表变化时回调
|
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
|
||||||
// Named listener field: fires when the active-session list changes
|
// Metadata/playback-state change callback: push in real time on track switch or play/pause
|
||||||
|
private val mediaCallback = object : MediaController.Callback() {
|
||||||
|
override fun onMetadataChanged(metadata: MediaMetadata?) {
|
||||||
|
Log.d(TAG, "onMetadataChanged: ${metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)}")
|
||||||
|
pushCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(state: PlaybackState?) {
|
||||||
|
Log.d(TAG, "onPlaybackStateChanged: state=${state?.state}")
|
||||||
|
pushCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活跃会话列表变化时回调
|
||||||
|
// Fires when the active-session list changes
|
||||||
private val activeSessionsListener =
|
private val activeSessionsListener =
|
||||||
MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
|
MediaSessionManager.OnActiveSessionsChangedListener { list ->
|
||||||
val cb = callback ?: return@OnActiveSessionsChangedListener
|
if (callback == null) return@OnActiveSessionsChangedListener
|
||||||
notifyMedia(controllers?.firstOrNull(), cb)
|
updateControllers(list?.toList() ?: emptyList())
|
||||||
|
pushCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
|
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
|
||||||
// 注册后主动拉取一次当前活跃会话:注册前已开始的播放不会触发变化回调,
|
// 注册后主动拉取一次当前会话:注册前已开始的播放不会触发变化回调。
|
||||||
// 主动拉取保证启动时媒体卡即可显示。
|
|
||||||
// Register the listener: idempotent — remove the old one first to avoid accumulation.
|
// Register the listener: idempotent — remove the old one first to avoid accumulation.
|
||||||
// After registering, actively pull the current sessions once: playback that started
|
// After registering, actively pull the current sessions once.
|
||||||
// before registration never fires the change callback, so the active pull makes the
|
fun start(cb: (List<MediaInfo>) -> Unit) {
|
||||||
// media card show immediately on startup.
|
|
||||||
fun start(cb: (MediaInfo?) -> Unit) {
|
|
||||||
stop()
|
stop()
|
||||||
callback = cb
|
callback = cb
|
||||||
try {
|
try {
|
||||||
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
|
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
|
||||||
registered = true
|
registered = true
|
||||||
Log.d(TAG, "start: media session listener registered")
|
Log.d(TAG, "start: media session listener registered")
|
||||||
val current = msm.getActiveSessions(listenerComponent).firstOrNull()
|
updateControllers(msm.getActiveSessions(listenerComponent))
|
||||||
notifyMedia(current, cb)
|
pushCurrent()
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
|
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
|
||||||
// 用户在系统设置开启通知使用权后,重启 Hearth 才会恢复媒体卡。
|
|
||||||
// MediaSessionService throws SecurityException without notification access;
|
// MediaSessionService throws SecurityException without notification access;
|
||||||
// degrade to media listening disabled instead of crashing. Media card recovers
|
// degrade to media listening disabled instead of crashing.
|
||||||
// after the user grants notification access and restarts Hearth.
|
|
||||||
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
|
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
|
||||||
registered = false
|
registered = false
|
||||||
}
|
}
|
||||||
@@ -65,44 +83,92 @@ class MediaSessionSource(context: Context) {
|
|||||||
registered = false
|
registered = false
|
||||||
Log.d(TAG, "stop: media session listener removed")
|
Log.d(TAG, "stop: media session listener removed")
|
||||||
}
|
}
|
||||||
|
unregisterCallbacks()
|
||||||
callback = null
|
callback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
|
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
|
||||||
// Actively pull the current session and notify (without re-registering), for
|
// Actively pull the current sessions and notify (without re-registering)
|
||||||
// refreshing the media card once the H5 page is ready
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val cb = callback ?: return
|
if (callback == null) return
|
||||||
try {
|
try {
|
||||||
val current = msm.getActiveSessions(listenerComponent).firstOrNull()
|
updateControllers(msm.getActiveSessions(listenerComponent))
|
||||||
notifyMedia(current, cb)
|
pushCurrent()
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.w(TAG, "refresh: no notification access: ${e.message}")
|
Log.w(TAG, "refresh: no notification access: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 controller 提取 metadata + playbackState 构造 MediaInfo 并回调
|
// 播放控制:作用于第一个(当前展示)controller
|
||||||
// Build MediaInfo from the controller's metadata + playbackState and invoke the callback
|
// Transport controls: act on the first (currently shown) controller
|
||||||
private fun notifyMedia(controller: MediaController?, cb: (MediaInfo?) -> Unit) {
|
fun previous() {
|
||||||
if (controller == null) {
|
controllers.firstOrNull()?.transportControls?.skipToPrevious()
|
||||||
Log.d(TAG, "no active media session")
|
Log.d(TAG, "previous")
|
||||||
cb(null)
|
}
|
||||||
return
|
|
||||||
|
fun next() {
|
||||||
|
controllers.firstOrNull()?.transportControls?.skipToNext()
|
||||||
|
Log.d(TAG, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playPause() {
|
||||||
|
val c = controllers.firstOrNull() ?: return
|
||||||
|
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
|
||||||
|
if (playing) c.transportControls.pause() else c.transportControls.play()
|
||||||
|
Log.d(TAG, "playPause: playing=$playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 controller 列表:注销旧回调、注册新回调
|
||||||
|
// Update the controller list: unregister old callbacks, register new ones
|
||||||
|
private fun updateControllers(list: List<MediaController>) {
|
||||||
|
unregisterCallbacks()
|
||||||
|
controllers.clear()
|
||||||
|
controllers.addAll(list)
|
||||||
|
controllers.forEach { it.registerCallback(mediaCallback) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterCallbacks() {
|
||||||
|
controllers.forEach {
|
||||||
|
try {
|
||||||
|
it.unregisterCallback(mediaCallback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val meta = controller.metadata
|
}
|
||||||
val state = controller.playbackState
|
|
||||||
cb(
|
// 推送当前全部会话的 MediaInfo 列表给 H5
|
||||||
MediaInfo(
|
// Push the current MediaInfo list for all sessions to H5
|
||||||
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
|
private fun pushCurrent() {
|
||||||
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
|
callback?.let { cb -> cb(controllers.map { toMediaInfo(it) }) }
|
||||||
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
|
}
|
||||||
state?.state == PlaybackState.STATE_PLAYING,
|
|
||||||
state?.position ?: 0L,
|
// 从 controller 提取 metadata + playbackState + 封面构造 MediaInfo
|
||||||
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
|
// Build MediaInfo from the controller's metadata + playbackState + cover
|
||||||
controller.packageName,
|
private fun toMediaInfo(c: MediaController): MediaInfo {
|
||||||
)
|
val meta = c.metadata
|
||||||
|
val state = c.playbackState
|
||||||
|
return MediaInfo(
|
||||||
|
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
|
||||||
|
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
|
||||||
|
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
|
||||||
|
meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) } ?: "",
|
||||||
|
state?.state == PlaybackState.STATE_PLAYING,
|
||||||
|
state?.position ?: 0L,
|
||||||
|
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
|
||||||
|
c.packageName,
|
||||||
)
|
)
|
||||||
Log.d(TAG, "media: ${controller.packageName}")
|
}
|
||||||
|
|
||||||
|
// 封面缩放到 128px,JPEG 压缩转 base64(data URI)
|
||||||
|
// Scale the cover to 128px, JPEG-compress to base64 (data URI)
|
||||||
|
private fun bitmapToBase64(bmp: Bitmap): String {
|
||||||
|
val size = 128
|
||||||
|
val scaled = Bitmap.createScaledBitmap(bmp, size, size, true)
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
|
||||||
|
scaled.recycle()
|
||||||
|
return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ class JsBridge(
|
|||||||
|
|
||||||
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
|
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
|
||||||
mediaSourceRef = source
|
mediaSourceRef = source
|
||||||
source.start { info -> pushMedia(info, webView) }
|
source.start { infos -> pushMedia(infos, webView) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
|
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
|
||||||
@@ -314,9 +314,26 @@ class JsBridge(
|
|||||||
mediaSourceRef?.refresh()
|
mediaSourceRef?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pushMedia(info: MediaInfo?, webView: android.webkit.WebView) {
|
// 媒体播放控制(作用于当前展示的会话)
|
||||||
|
// Media transport controls (act on the currently shown session)
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun mediaPrevious() {
|
||||||
|
mediaSourceRef?.previous()
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun mediaPlayPause() {
|
||||||
|
mediaSourceRef?.playPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun mediaNext() {
|
||||||
|
mediaSourceRef?.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {
|
||||||
webView.post {
|
webView.post {
|
||||||
val json = info?.toJson() ?: "null"
|
val json = gson.toJson(infos)
|
||||||
webView.evaluateJavascript(
|
webView.evaluateJavascript(
|
||||||
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
|
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
|
||||||
null
|
null
|
||||||
|
|||||||
Reference in New Issue
Block a user