feat: page preload, card grid span, wallpaper into webview for glass blur

This commit is contained in:
2026-08-16 19:51:08 +08:00
parent 1a79b4ed55
commit 8c42dca7bc
8 changed files with 175 additions and 47 deletions
+46 -13
View File
@@ -120,25 +120,22 @@ body {
to { opacity: 1; transform: none; }
}
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */
/* Home three-column layout: three equal vertical columns, cards stack downward */
/* 首页卡片 Grid:三等分列,卡片可跨栏(span-2/3),行均分高度 */
/* Home card grid: three equal columns, cards can span (span-2/3), rows split evenly */
.tri-col {
flex: 1;
min-height: 0;
display: flex;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
gap: 16px;
padding: 16px;
}
.col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.card.span-2 { grid-column: span 2; }
.card.span-3 { grid-column: span 3; }
/* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
.card {
@@ -161,7 +158,9 @@ body {
/* 时钟卡缩放权重小:占栏高比例小于其他卡片 */
/* Time card grows less: takes a smaller share of column height than other cards */
.time-card {
flex-grow: 0.5;
display: flex;
flex-direction: column;
justify-content: center;
}
.time-card .big {
@@ -185,6 +184,13 @@ body {
color: var(--text-dim);
}
/* 媒体卡:flex column,标题靠上、进度/控制靠下 */
/* Media card: flex column, title top, progress/controls bottom */
.media-card {
display: flex;
flex-direction: column;
}
/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */
/* Media card: playing caption + title/artist + progress bar */
.media-card .cap {
@@ -212,7 +218,8 @@ body {
}
.media-card .prog {
margin-top: 12px;
margin-top: auto;
padding-top: 12px;
height: 4px;
border-radius: 999px;
background: var(--glass-border);
@@ -225,6 +232,32 @@ body {
background: var(--accent);
}
/* 媒体控制按钮:上一首/暂停/下一首 */
/* Media controls: previous / play-pause / next */
.media-card .controls {
margin-top: 12px;
display: flex;
gap: 10px;
}
.media-card .controls button {
width: 36px;
height: 36px;
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;
}
.media-card .controls button:active {
opacity: 0.6;
}
/* 安卓 APP 列表页:搜索框 + 网格 */
/* Android app list page: search box + grid */
.search { padding: 16px; }
+14
View File
@@ -25,6 +25,7 @@
</main>
</div>
<script src="js/bridge.js"></script>
<script src="js/preload.js"></script>
<script src="js/mask.js"></script>
<script src="js/cards/cards.js"></script>
<script src="js/router.js"></script>
@@ -55,6 +56,19 @@
// 初始化背景遮罩(夜间主题 + 已启用时生效)
// Apply wallpaper dim on startup (active under dark theme when enabled)
window.mask.apply();
// 加载壁纸为 body 背景:玻璃效果 backdrop-filter 需要 WebView 内部有背景可模糊
// Load the wallpaper as the body background: the glass backdrop-filter needs
// in-WebView content behind cards to blur
bridge.call('getWallpaper').then(wp => {
if (wp) {
document.body.style.backgroundImage = 'url(' + wp + ')';
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
}
}).catch(() => {});
// 首屏渲染后延迟预加载其他页面数据,减少切页等待
// Preload other pages' data after first paint to reduce switch latency
setTimeout(() => window.preload.loadAll(), 800);
</script>
</body>
</html>
+3 -1
View File
@@ -6,7 +6,9 @@ window.loadAppList = async function () {
page.innerHTML = `
<div class="search"><input id="app-search" placeholder="搜索应用…"></div>
<div class="grid" id="app-grid"></div>`;
const apps = JSON.parse(await bridge.call('listApps'));
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
const apps = window.preload.apps || JSON.parse(await bridge.call('listApps'));
const grid = document.getElementById('app-grid');
const render = (list) => {
grid.textContent = '';
+33 -32
View File
@@ -3,12 +3,12 @@
// (big time card + generic placeholders for the rest)
window.updateHomeCards = async function () {
const page = document.querySelector('[data-page="home"]');
page.innerHTML = `<div class="tri-col" id="tri-col">
<div class="col" id="col-0"></div><div class="col" id="col-1"></div><div class="col" id="col-2"></div>
</div>`;
page.innerHTML = `<div class="tri-col" id="tri-col"></div>`;
let catalog = [];
try {
catalog = JSON.parse(await bridge.call('fetchCards'));
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
catalog = window.preload.cards || JSON.parse(await bridge.call('fetchCards'));
} catch (e) {
// 桥接不可用(如未接线)时按空目录处理
// Treat bridge failure as an empty catalog
@@ -20,27 +20,33 @@ window.updateHomeCards = async function () {
if (!catalog.some((c) => c.id === 'time')) {
catalog.push({ id: 'time', name: '大字时间', priority: 0 });
}
const cols = cards.layout(catalog);
cols.forEach((ids, i) => {
const col = document.getElementById(`col-${i}`);
ids.forEach((id) => {
if (id === 'time') col.appendChild(renderTimeCard());
else col.appendChild(renderGenericCard(id));
});
});
// 若有当前媒体,覆盖中间栏为媒体卡(切页回来后恢复媒体卡)
// If there is active media, replace the middle column with the media card
// (restores the media card when navigating back to home)
if (currentMedia) {
const mediaCol = document.getElementById('col-1');
if (mediaCol) {
mediaCol.innerHTML = '';
mediaCol.appendChild(renderMediaCard(currentMedia));
}
}
renderCards(catalog);
startTimeCardTicker();
};
// 卡片 Grid 渲染:media 卡 span 2(跨栏),time/其他卡 span 1
// Card grid render: media card spans 2 columns, time/other cards span 1
function renderCards(catalog) {
const grid = document.getElementById('tri-col');
grid.textContent = '';
const items = [];
// 按 priority 排序(数字小者优先)
// Sort by priority (smaller first)
const sorted = [...catalog].sort((a, b) => (a.priority || 0) - (b.priority || 0));
// 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) });
// 其他卡(排除 timespan 1
sorted.filter(c => c.id !== 'time').forEach(c => {
items.push({ span: 1, el: renderGenericCard(c.id) });
});
items.forEach(item => {
item.el.classList.add('span-' + item.span);
grid.appendChild(item.el);
});
}
// 内置大字时间卡:当前时间(时:分 + 小号秒)+ 日期
// Builtin big time card: current time (HH:MM + small seconds) + date
function renderTimeCard() {
@@ -100,16 +106,11 @@ let currentMedia = null;
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (info) {
currentMedia = info;
const mediaCol = document.getElementById('col-1');
if (!mediaCol) return;
if (info) {
mediaCol.innerHTML = '';
mediaCol.appendChild(renderMediaCard(info));
} else {
// 无媒体:降级到日历/天气(由 cards.layout 重新计算)
// No media: fall back to calendar/weather (recomputed by cards.layout)
window.updateHomeCards();
}
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();
};
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条
@@ -6,7 +6,9 @@ window.loadWebAppList = async function () {
page.innerHTML = `
<div class="search"><input id="web-search" placeholder="搜索 webAPP…"></div>
<div class="grid" id="web-grid"></div>`;
const apps = JSON.parse(await bridge.call('fetchWebApps'));
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
const apps = window.preload.webApps || JSON.parse(await bridge.call('fetchWebApps'));
const grid = document.getElementById('web-grid');
const render = (list) => {
grid.textContent = '';
+24
View File
@@ -0,0 +1,24 @@
// 页面数据预加载:启动后后台拉取各页面数据缓存,切页时直接渲染减少延迟
// Page data preload: fetch and cache page data in the background after startup,
// so page switches render immediately without a fetch delay
window.preload = {
apps: null,
webApps: null,
cards: null,
async loadAll() {
try {
const [apps, webApps, cards] = await Promise.all([
bridge.call('listApps').catch(() => null),
bridge.call('fetchWebApps').catch(() => null),
bridge.call('fetchCards').catch(() => null),
]);
this.apps = apps ? JSON.parse(apps) : null;
this.webApps = webApps ? JSON.parse(webApps) : null;
this.cards = cards ? JSON.parse(cards) : null;
} catch (e) {
// 预加载失败不阻塞:切页时仍会实时加载
// Preload failure is non-blocking: page switches still fetch live
}
},
};
@@ -1,12 +1,16 @@
package top.yeij.hearth
import android.app.Activity
import android.app.WallpaperManager
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -30,8 +34,10 @@ import top.yeij.hearth.webview.BrightnessProvider
import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.NotificationAccessProvider
import top.yeij.hearth.webview.ServerUrlProvider
import top.yeij.hearth.webview.WallpaperProvider
import top.yeij.hearth.webview.WebAppHost
import top.yeij.hearth.webview.WebViewManager
import java.io.ByteArrayOutputStream
import java.io.File
// HOME 桌面 activity,接线各能力层:Repository、媒体监听、多 WebView webAPP 管理
@@ -106,6 +112,35 @@ class MainActivity : Activity() {
}
}
// 壁纸提供实现:WallpaperManager 取当前壁纸转 base64,供 H5 body 背景使用
// Wallpaper implementation: WallpaperManager -> base64 for the H5 body background
private val wallpaperProvider = object : WallpaperProvider {
override fun getWallpaperBase64(): String {
return try {
val wm = WallpaperManager.getInstance(this@MainActivity)
// 动态壁纸(Live Wallpaper)是实时渲染、无静态 Drawable,返回空让 H5 保持
// 透明透出动态壁纸;此时玻璃模糊降级为普通半透明(backdrop-filter 无内容可模糊)
// Live wallpapers render in real time with no static drawable; return empty
// so H5 stays transparent and shows the live wallpaper, with the glass blur
// degrading to plain translucency (backdrop-filter has nothing to blur)
if (wm.wallpaperInfo != null) return ""
val d = wm.drawable ?: return ""
val dm = resources.displayMetrics
val bmp = Bitmap.createBitmap(dm.widthPixels, dm.heightPixels, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, dm.widthPixels, dm.heightPixels)
d.draw(canvas)
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 80, out)
bmp.recycle()
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "getWallpaper: ${e.message}")
""
}
}
}
private lateinit var webAppRepository: WebAppRepository
private lateinit var mediaSource: MediaSessionSource
private lateinit var root: FrameLayout
@@ -155,6 +190,7 @@ class MainActivity : Activity() {
notificationAccess = notificationAccess,
brightness = brightnessProvider,
serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider,
)
root = FrameLayout(this)
setContentView(root)
@@ -36,6 +36,14 @@ interface ServerUrlProvider {
fun setServerUrl(url: String)
}
// 壁纸提供接口:由 MainActivity 实现(WallpaperManager 转 base64),
// 让壁纸进入 WebView 内部,backdrop-filter 玻璃才能模糊到壁纸
// Wallpaper provider implemented by MainActivity (WallpaperManager -> base64), so the
// wallpaper lives inside the WebView and the glass backdrop-filter can blur it
interface WallpaperProvider {
fun getWallpaperBase64(): String
}
class JsBridge(
private val deviceWidthPx: Int,
private val deviceHeightPx: Int,
@@ -58,6 +66,9 @@ class JsBridge(
// 服务器地址存储接口
// Server URL storage provider
private val serverUrl: ServerUrlProvider? = null,
// 壁纸提供接口(base64
// Wallpaper provider (base64)
private val wallpaper: WallpaperProvider? = null,
) {
private val gson = com.google.gson.Gson()
@@ -270,6 +281,11 @@ class JsBridge(
serverUrl?.setServerUrl(url)
}
// 返回壁纸 base64(data URI,未接入返回空字符串)
// Return the wallpaper base64 (data URI; empty string when not wired)
@android.webkit.JavascriptInterface
fun getWallpaper(): String = wallpaper?.getWallpaperBase64() ?: ""
// 重新拉取 webAPP 清单 + 卡片目录,返回 { count }
// Re-fetch the webAPP manifest + card catalog, return { count }
@android.webkit.JavascriptInterface