fix: grid minmax, media polling fallback, content webview layout, swipe timeout
This commit is contained in:
@@ -126,7 +126,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -172,7 +172,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-card .big {
|
.time-card .big {
|
||||||
font-size: 56px;
|
font-size: clamp(26px, 3.5vw, 52px);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
/* 等宽数字:1 和 0 等宽,避免时间刷新时卡片宽度抖动 */
|
/* 等宽数字:1 和 0 等宽,避免时间刷新时卡片宽度抖动 */
|
||||||
@@ -275,9 +275,8 @@ body {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
overflow: hidden;
|
white-space: normal;
|
||||||
text-overflow: ellipsis;
|
word-break: break-word;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度条 */
|
/* 进度条 */
|
||||||
@@ -298,6 +297,18 @@ body {
|
|||||||
transition: width .3s linear;
|
transition: width .3s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 无时长信息时的活动脉冲进度条 */
|
||||||
|
/* Indeterminate pulse when no duration is available */
|
||||||
|
.media-card .bar.indeterminate {
|
||||||
|
width: 40%;
|
||||||
|
animation: bar-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bar-pulse {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
50% { transform: translateX(150%); }
|
||||||
|
}
|
||||||
|
|
||||||
/* 控制按钮:SVG 图标 + 按压动画 */
|
/* 控制按钮:SVG 图标 + 按压动画 */
|
||||||
/* Controls: SVG icons + press animation */
|
/* Controls: SVG icons + press animation */
|
||||||
.media-card .controls {
|
.media-card .controls {
|
||||||
|
|||||||
@@ -192,7 +192,13 @@ function startMediaTicker() {
|
|||||||
const bar = document.querySelector('.media-card .bar');
|
const bar = document.querySelector('.media-card .bar');
|
||||||
if (!bar || !mediaProgressState) return;
|
if (!bar || !mediaProgressState) return;
|
||||||
const { position, duration, playing, updatedAt } = mediaProgressState;
|
const { position, duration, playing, updatedAt } = mediaProgressState;
|
||||||
if (!duration) return;
|
if (!duration) {
|
||||||
|
// 无时长信息(部分 App 不提供 duration):显示活动脉冲而非空白
|
||||||
|
// No duration (some apps omit it): show an indeterminate pulse instead of blank
|
||||||
|
bar.classList.add('indeterminate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.classList.remove('indeterminate');
|
||||||
const pos = playing ? position + (Date.now() - updatedAt) : position;
|
const pos = playing ? position + (Date.now() - updatedAt) : position;
|
||||||
bar.style.width = Math.min(100, (pos / duration) * 100) + '%';
|
bar.style.width = Math.min(100, (pos / duration) * 100) + '%';
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -242,11 +248,15 @@ function switchMedia(delta) {
|
|||||||
const outClass = delta > 0 ? 'slide-out-left' : 'slide-out-right';
|
const outClass = delta > 0 ? 'slide-out-left' : 'slide-out-right';
|
||||||
const inClass = delta > 0 ? 'slide-in-right' : 'slide-in-left';
|
const inClass = delta > 0 ? 'slide-in-right' : 'slide-in-left';
|
||||||
old.classList.add(outClass);
|
old.classList.add(outClass);
|
||||||
old.addEventListener('animationend', () => {
|
// 用 setTimeout 替代 animationend(更可靠,避免动画事件丢失导致"只能划一次")
|
||||||
|
// Use setTimeout instead of animationend (more reliable, avoids the "swipe once
|
||||||
|
// then stuck" issue when the animation event is lost)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!old.isConnected) return;
|
||||||
const fresh = renderMediaCard(info);
|
const fresh = renderMediaCard(info);
|
||||||
fresh.classList.add(inClass);
|
fresh.classList.add(inClass);
|
||||||
old.replaceWith(fresh);
|
old.replaceWith(fresh);
|
||||||
}, { once: true });
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转义 HTML 特殊字符,避免媒体元数据注入标签
|
// 转义 HTML 特殊字符,避免媒体元数据注入标签
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ class MainActivity : Activity() {
|
|||||||
// 内容 WebView 顶栏预留高度(dp)
|
// 内容 WebView 顶栏预留高度(dp)
|
||||||
// Topbar reserved height for content WebViews (dp)
|
// Topbar reserved height for content WebViews (dp)
|
||||||
private const val TOPBAR_HEIGHT_DP = 44
|
private const val TOPBAR_HEIGHT_DP = 44
|
||||||
|
// 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏
|
||||||
|
// Global sidebar collapsed width (dp); content WebViews reserve it on the
|
||||||
|
// left to avoid covering the sidebar
|
||||||
|
private const val SIDEBAR_WIDTH_DP = 80
|
||||||
}
|
}
|
||||||
|
|
||||||
private val webAppContainer = WebAppContainer()
|
private val webAppContainer = WebAppContainer()
|
||||||
@@ -237,6 +241,9 @@ class MainActivity : Activity() {
|
|||||||
private val topBarHeightPx by lazy {
|
private val topBarHeightPx by lazy {
|
||||||
(TOPBAR_HEIGHT_DP * resources.displayMetrics.density).toInt()
|
(TOPBAR_HEIGHT_DP * resources.displayMetrics.density).toInt()
|
||||||
}
|
}
|
||||||
|
private val sidebarWidthPx by lazy {
|
||||||
|
(SIDEBAR_WIDTH_DP * resources.displayMetrics.density).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
override fun openWebView(id: String, url: String) {
|
override fun openWebView(id: String, url: String) {
|
||||||
if (contentWebViews.containsKey(id)) {
|
if (contentWebViews.containsKey(id)) {
|
||||||
@@ -261,6 +268,7 @@ class MainActivity : Activity() {
|
|||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
params.topMargin = topBarHeightPx
|
params.topMargin = topBarHeightPx
|
||||||
|
params.leftMargin = sidebarWidthPx
|
||||||
contentWebViews[id] = webView
|
contentWebViews[id] = webView
|
||||||
root.addView(webView, params)
|
root.addView(webView, params)
|
||||||
webView.loadUrl(url)
|
webView.loadUrl(url)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -33,6 +35,18 @@ class MediaSessionSource(context: Context) {
|
|||||||
private var registered = false
|
private var registered = false
|
||||||
private val controllers = mutableListOf<MediaController>()
|
private val controllers = mutableListOf<MediaController>()
|
||||||
|
|
||||||
|
// 轮询兜底:registerCallback 在部分场景不触发(如歌词实时更新时),
|
||||||
|
// 每 2 秒主动拉取一次进度/状态,保证进度条与信息持续更新
|
||||||
|
// Polling fallback: registerCallback doesn't always fire (e.g. live lyrics),
|
||||||
|
// so actively pull progress/state every 2s to keep the bar and info fresh
|
||||||
|
private val pollHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val pollRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
refresh()
|
||||||
|
pollHandler.postDelayed(this, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
|
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
|
||||||
// Metadata/playback-state change callback: push in real time on track switch or play/pause
|
// Metadata/playback-state change callback: push in real time on track switch or play/pause
|
||||||
private val mediaCallback = object : MediaController.Callback() {
|
private val mediaCallback = object : MediaController.Callback() {
|
||||||
@@ -69,6 +83,9 @@ class MediaSessionSource(context: Context) {
|
|||||||
Log.d(TAG, "start: media session listener registered")
|
Log.d(TAG, "start: media session listener registered")
|
||||||
updateControllers(msm.getActiveSessions(listenerComponent))
|
updateControllers(msm.getActiveSessions(listenerComponent))
|
||||||
pushCurrent()
|
pushCurrent()
|
||||||
|
// 启动轮询兜底
|
||||||
|
pollHandler.removeCallbacks(pollRunnable)
|
||||||
|
pollHandler.postDelayed(pollRunnable, 2000)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
|
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
|
||||||
// MediaSessionService throws SecurityException without notification access;
|
// MediaSessionService throws SecurityException without notification access;
|
||||||
@@ -87,6 +104,7 @@ class MediaSessionSource(context: Context) {
|
|||||||
Log.d(TAG, "stop: media session listener removed")
|
Log.d(TAG, "stop: media session listener removed")
|
||||||
}
|
}
|
||||||
unregisterCallbacks()
|
unregisterCallbacks()
|
||||||
|
pollHandler.removeCallbacks(pollRunnable)
|
||||||
callback = null
|
callback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ class WebViewManager(private val activity: Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建内容 WebView(webAPP 标签页),仅配置不加载、不挂载(由宿主管理)
|
// 创建内容 WebView(webAPP 标签页),仅配置不加载、不挂载(由宿主管理)
|
||||||
|
// 透明背景:webAPP 未声明背景色时透出桌面壁纸,而非默认白底
|
||||||
// Create a content WebView (webapp tab), configured but not loaded/attached
|
// Create a content WebView (webapp tab), configured but not loaded/attached
|
||||||
// (lifecycle is managed by the host)
|
// (lifecycle is managed by the host); transparent background so a webapp without
|
||||||
|
// a declared background shows the wallpaper instead of a default white
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
fun createContentWebView(): WebView {
|
fun createContentWebView(): WebView {
|
||||||
val webView = WebView(activity)
|
val webView = WebView(activity)
|
||||||
webView.setBackgroundColor(Color.WHITE)
|
webView.setBackgroundColor(Color.TRANSPARENT)
|
||||||
configure(webView)
|
configure(webView)
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user