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; } to { opacity: 1; transform: none; }
} }
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */ /* 首页卡片 Grid:三等分列,卡片可跨栏(span-2/3),行均分高度 */
/* Home three-column layout: three equal vertical columns, cards stack downward */ /* Home card grid: three equal columns, cards can span (span-2/3), rows split evenly */
.tri-col { .tri-col {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
}
.col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto; overflow-y: auto;
} }
.card.span-2 { grid-column: span 2; }
.card.span-3 { grid-column: span 3; }
/* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */ /* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */ /* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
.card { .card {
@@ -161,7 +158,9 @@ body {
/* 时钟卡缩放权重小:占栏高比例小于其他卡片 */ /* 时钟卡缩放权重小:占栏高比例小于其他卡片 */
/* Time card grows less: takes a smaller share of column height than other cards */ /* Time card grows less: takes a smaller share of column height than other cards */
.time-card { .time-card {
flex-grow: 0.5; display: flex;
flex-direction: column;
justify-content: center;
} }
.time-card .big { .time-card .big {
@@ -185,6 +184,13 @@ body {
color: var(--text-dim); 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: playing caption + title/artist + progress bar */
.media-card .cap { .media-card .cap {
@@ -212,7 +218,8 @@ body {
} }
.media-card .prog { .media-card .prog {
margin-top: 12px; margin-top: auto;
padding-top: 12px;
height: 4px; height: 4px;
border-radius: 999px; border-radius: 999px;
background: var(--glass-border); background: var(--glass-border);
@@ -225,6 +232,32 @@ body {
background: var(--accent); 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 列表页:搜索框 + 网格 */ /* 安卓 APP 列表页:搜索框 + 网格 */
/* Android app list page: search box + grid */ /* Android app list page: search box + grid */
.search { padding: 16px; } .search { padding: 16px; }
+14
View File
@@ -25,6 +25,7 @@
</main> </main>
</div> </div>
<script src="js/bridge.js"></script> <script src="js/bridge.js"></script>
<script src="js/preload.js"></script>
<script src="js/mask.js"></script> <script src="js/mask.js"></script>
<script src="js/cards/cards.js"></script> <script src="js/cards/cards.js"></script>
<script src="js/router.js"></script> <script src="js/router.js"></script>
@@ -55,6 +56,19 @@
// 初始化背景遮罩(夜间主题 + 已启用时生效) // 初始化背景遮罩(夜间主题 + 已启用时生效)
// Apply wallpaper dim on startup (active under dark theme when enabled) // Apply wallpaper dim on startup (active under dark theme when enabled)
window.mask.apply(); 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> </script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -6,7 +6,9 @@ window.loadAppList = async function () {
page.innerHTML = ` page.innerHTML = `
<div class="search"><input id="app-search" placeholder="搜索应用…"></div> <div class="search"><input id="app-search" placeholder="搜索应用…"></div>
<div class="grid" id="app-grid"></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 grid = document.getElementById('app-grid');
const render = (list) => { const render = (list) => {
grid.textContent = ''; grid.textContent = '';
+33 -32
View File
@@ -3,12 +3,12 @@
// (big time card + generic placeholders for the rest) // (big time card + generic placeholders for the rest)
window.updateHomeCards = async function () { window.updateHomeCards = async function () {
const page = document.querySelector('[data-page="home"]'); const page = document.querySelector('[data-page="home"]');
page.innerHTML = `<div class="tri-col" id="tri-col"> page.innerHTML = `<div class="tri-col" id="tri-col"></div>`;
<div class="col" id="col-0"></div><div class="col" id="col-1"></div><div class="col" id="col-2"></div>
</div>`;
let catalog = []; let catalog = [];
try { 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) { } catch (e) {
// 桥接不可用(如未接线)时按空目录处理 // 桥接不可用(如未接线)时按空目录处理
// Treat bridge failure as an empty catalog // Treat bridge failure as an empty catalog
@@ -20,27 +20,33 @@ window.updateHomeCards = async function () {
if (!catalog.some((c) => c.id === 'time')) { if (!catalog.some((c) => c.id === 'time')) {
catalog.push({ id: 'time', name: '大字时间', priority: 0 }); catalog.push({ id: 'time', name: '大字时间', priority: 0 });
} }
const cols = cards.layout(catalog); renderCards(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));
}
}
startTimeCardTicker(); 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 // Builtin big time card: current time (HH:MM + small seconds) + date
function renderTimeCard() { function renderTimeCard() {
@@ -100,16 +106,11 @@ let currentMedia = null;
window.HearthEvents = window.HearthEvents || {}; window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (info) { window.HearthEvents.mediaSessionChanged = function (info) {
currentMedia = info; currentMedia = info;
const mediaCol = document.getElementById('col-1'); const grid = document.getElementById('tri-col');
if (!mediaCol) return; if (!grid) return;
if (info) { // 重新渲染:有媒体时 media 卡 span 2,无媒体时其他卡填满
mediaCol.innerHTML = ''; // Re-render: media card spans 2 when present, others fill otherwise
mediaCol.appendChild(renderMediaCard(info)); window.updateHomeCards();
} else {
// 无媒体:降级到日历/天气(由 cards.layout 重新计算)
// No media: fall back to calendar/weather (recomputed by cards.layout)
window.updateHomeCards();
}
}; };
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 // 媒体卡:正在播放标签 + 标题/艺术家 + 进度条
@@ -6,7 +6,9 @@ window.loadWebAppList = async function () {
page.innerHTML = ` page.innerHTML = `
<div class="search"><input id="web-search" placeholder="搜索 webAPP…"></div> <div class="search"><input id="web-search" placeholder="搜索 webAPP…"></div>
<div class="grid" id="web-grid"></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 grid = document.getElementById('web-grid');
const render = (list) => { const render = (list) => {
grid.textContent = ''; 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 package top.yeij.hearth
import android.app.Activity import android.app.Activity
import android.app.WallpaperManager
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
import android.util.Base64
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup 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.JsBridge
import top.yeij.hearth.webview.NotificationAccessProvider import top.yeij.hearth.webview.NotificationAccessProvider
import top.yeij.hearth.webview.ServerUrlProvider import top.yeij.hearth.webview.ServerUrlProvider
import top.yeij.hearth.webview.WallpaperProvider
import top.yeij.hearth.webview.WebAppHost import top.yeij.hearth.webview.WebAppHost
import top.yeij.hearth.webview.WebViewManager import top.yeij.hearth.webview.WebViewManager
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
// HOME 桌面 activity,接线各能力层:Repository、媒体监听、多 WebView webAPP 管理 // 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 webAppRepository: WebAppRepository
private lateinit var mediaSource: MediaSessionSource private lateinit var mediaSource: MediaSessionSource
private lateinit var root: FrameLayout private lateinit var root: FrameLayout
@@ -155,6 +190,7 @@ class MainActivity : Activity() {
notificationAccess = notificationAccess, notificationAccess = notificationAccess,
brightness = brightnessProvider, brightness = brightnessProvider,
serverUrl = serverUrlProvider, serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider,
) )
root = FrameLayout(this) root = FrameLayout(this)
setContentView(root) setContentView(root)
@@ -36,6 +36,14 @@ interface ServerUrlProvider {
fun setServerUrl(url: String) 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( class JsBridge(
private val deviceWidthPx: Int, private val deviceWidthPx: Int,
private val deviceHeightPx: Int, private val deviceHeightPx: Int,
@@ -58,6 +66,9 @@ class JsBridge(
// 服务器地址存储接口 // 服务器地址存储接口
// Server URL storage provider // Server URL storage provider
private val serverUrl: ServerUrlProvider? = null, private val serverUrl: ServerUrlProvider? = null,
// 壁纸提供接口(base64
// Wallpaper provider (base64)
private val wallpaper: WallpaperProvider? = null,
) { ) {
private val gson = com.google.gson.Gson() private val gson = com.google.gson.Gson()
@@ -270,6 +281,11 @@ class JsBridge(
serverUrl?.setServerUrl(url) 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 } // 重新拉取 webAPP 清单 + 卡片目录,返回 { count }
// Re-fetch the webAPP manifest + card catalog, return { count } // Re-fetch the webAPP manifest + card catalog, return { count }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface