From 8c42dca7bccae77159b9897fa0e96897dd00000f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 16 Aug 2026 19:51:08 +0800 Subject: [PATCH] feat: page preload, card grid span, wallpaper into webview for glass blur --- app/src/main/assets/h5/css/app.css | 59 +++++++++++++---- app/src/main/assets/h5/index.html | 14 ++++ app/src/main/assets/h5/js/pages/applist.js | 4 +- app/src/main/assets/h5/js/pages/home.js | 65 ++++++++++--------- app/src/main/assets/h5/js/pages/webapplist.js | 4 +- app/src/main/assets/h5/js/preload.js | 24 +++++++ .../main/java/top/yeij/hearth/MainActivity.kt | 36 ++++++++++ .../java/top/yeij/hearth/webview/JsBridge.kt | 16 +++++ 8 files changed, 175 insertions(+), 47 deletions(-) create mode 100644 app/src/main/assets/h5/js/preload.js diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css index 48fddb7..bc604bc 100644 --- a/app/src/main/assets/h5/css/app.css +++ b/app/src/main/assets/h5/css/app.css @@ -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; } diff --git a/app/src/main/assets/h5/index.html b/app/src/main/assets/h5/index.html index e70c516..12a0c12 100644 --- a/app/src/main/assets/h5/index.html +++ b/app/src/main/assets/h5/index.html @@ -25,6 +25,7 @@ + @@ -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); diff --git a/app/src/main/assets/h5/js/pages/applist.js b/app/src/main/assets/h5/js/pages/applist.js index f44e966..2ae3b06 100644 --- a/app/src/main/assets/h5/js/pages/applist.js +++ b/app/src/main/assets/h5/js/pages/applist.js @@ -6,7 +6,9 @@ window.loadAppList = async function () { page.innerHTML = `
`; - 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 = ''; diff --git a/app/src/main/assets/h5/js/pages/home.js b/app/src/main/assets/h5/js/pages/home.js index 4a47b52..99d07a5 100644 --- a/app/src/main/assets/h5/js/pages/home.js +++ b/app/src/main/assets/h5/js/pages/home.js @@ -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 = `
-
-
`; + page.innerHTML = `
`; 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) }); + // 其他卡(排除 time)span 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(); }; // 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 diff --git a/app/src/main/assets/h5/js/pages/webapplist.js b/app/src/main/assets/h5/js/pages/webapplist.js index a66fac2..0d413ed 100644 --- a/app/src/main/assets/h5/js/pages/webapplist.js +++ b/app/src/main/assets/h5/js/pages/webapplist.js @@ -6,7 +6,9 @@ window.loadWebAppList = async function () { page.innerHTML = `
`; - 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 = ''; diff --git a/app/src/main/assets/h5/js/preload.js b/app/src/main/assets/h5/js/preload.js new file mode 100644 index 0000000..2b80030 --- /dev/null +++ b/app/src/main/assets/h5/js/preload.js @@ -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 + } + }, +}; diff --git a/app/src/main/java/top/yeij/hearth/MainActivity.kt b/app/src/main/java/top/yeij/hearth/MainActivity.kt index 33b55a5..6527446 100644 --- a/app/src/main/java/top/yeij/hearth/MainActivity.kt +++ b/app/src/main/java/top/yeij/hearth/MainActivity.kt @@ -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) diff --git a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt index 4bfbd6f..e5158e3 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -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