diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css
index 701e32f..fac6438 100644
--- a/app/src/main/assets/h5/css/app.css
+++ b/app/src/main/assets/h5/css/app.css
@@ -42,9 +42,10 @@ body {
.rail {
width: 80px;
background: var(--glass-bg);
- backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
- -webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
+ backdrop-filter: var(--blur-filter);
+ -webkit-backdrop-filter: var(--blur-filter);
border-right: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
display: flex;
flex-direction: column;
align-items: center;
@@ -70,6 +71,8 @@ body {
display: flex;
flex-direction: column;
align-items: center;
+ justify-content: center;
+ width: 100%;
gap: 3px;
background: none;
border: none;
@@ -87,7 +90,7 @@ body {
justify-content: center;
}
.rail-item .ico svg { width: 100%; height: 100%; display: block; }
-.rail-item .lbl { font-size: 11px; }
+.rail-item .lbl { font-size: 11px; text-align: center; width: 100%; }
.rail-item.active { background: var(--accent); color: #fff; }
@@ -140,9 +143,10 @@ body {
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
.card {
background: var(--glass-bg);
- backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
- -webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
+ backdrop-filter: var(--blur-filter);
+ -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
border-radius: 14px;
padding: 20px;
color: var(--text);
@@ -298,8 +302,8 @@ body {
padding: 6px 10px;
border-radius: 999px;
background: var(--glass-bg);
- backdrop-filter: blur(20px) saturate(1.2);
- -webkit-backdrop-filter: blur(20px) saturate(1.2);
+ backdrop-filter: var(--blur-filter);
+ -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
z-index: 100;
}
@@ -343,8 +347,8 @@ body {
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
- backdrop-filter: blur(20px) saturate(1.2);
- -webkit-backdrop-filter: blur(20px) saturate(1.2);
+ backdrop-filter: var(--blur-filter);
+ -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
z-index: 100;
}
@@ -408,9 +412,10 @@ body {
margin-bottom: 8px;
border-radius: 14px;
background: var(--glass-bg);
- backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
- -webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
+ backdrop-filter: var(--blur-filter);
+ -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
color: var(--text);
}
diff --git a/app/src/main/assets/h5/css/tokens.css b/app/src/main/assets/h5/css/tokens.css
index a22c151..6001c49 100644
--- a/app/src/main/assets/h5/css/tokens.css
+++ b/app/src/main/assets/h5/css/tokens.css
@@ -6,6 +6,18 @@
--text: #1a1a1a;
--text-dim: #8a8a90;
--accent: #ff6900;
+ /* 玻璃效果默认关闭:无模糊、无高光(普通半透明) */
+ /* Glass effect off by default: no blur, no highlight (plain translucent) */
+ --blur-filter: none;
+ --glass-shadow: none;
+}
+
+/* 玻璃效果开启:磨砂模糊 + 边缘反射高光(液态玻璃,参考 Apple WWDC25) */
+/* Glass effect on: frosted blur + edge-reflection highlight (liquid glass) */
+:root[data-glass="on"] {
+ --blur-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
+ --glass-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18),
+ inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
/* 系统深色:仅在未强制浅色时生效(跟随系统) */
diff --git a/app/src/main/assets/h5/index.html b/app/src/main/assets/h5/index.html
index 8286d9e..e70c516 100644
--- a/app/src/main/assets/h5/index.html
+++ b/app/src/main/assets/h5/index.html
@@ -7,6 +7,7 @@
+
diff --git a/app/src/main/assets/h5/js/glass.js b/app/src/main/assets/h5/js/glass.js
new file mode 100644
index 0000000..957e0c2
--- /dev/null
+++ b/app/src/main/assets/h5/js/glass.js
@@ -0,0 +1,24 @@
+// 玻璃效果开关:普通半透明(默认)/ 液态玻璃(backdrop-filter 磨砂 + 边缘高光)
+// Glass effect toggle: plain translucent (default) / liquid glass (backdrop-filter
+// frost + edge highlight), persisted in localStorage
+(function () {
+ const KEY = 'hearth-glass';
+
+ window.glass = {
+ enabled() {
+ return localStorage.getItem(KEY) === '1';
+ },
+ setEnabled(on) {
+ localStorage.setItem(KEY, on ? '1' : '0');
+ this.apply();
+ },
+ // 应用开关:写 data-glass 属性,CSS 据此切换 --blur-filter 等变量
+ // Apply: set the data-glass attribute; CSS switches --blur-filter etc. from it
+ apply() {
+ document.documentElement.setAttribute('data-glass', this.enabled() ? 'on' : 'off');
+ },
+ };
+
+ // 启动即应用(默认关闭 = 普通半透明)
+ window.glass.apply();
+})();
diff --git a/app/src/main/assets/h5/js/pages/settings.js b/app/src/main/assets/h5/js/pages/settings.js
index cc2e887..293a958 100644
--- a/app/src/main/assets/h5/js/pages/settings.js
+++ b/app/src/main/assets/h5/js/pages/settings.js
@@ -10,6 +10,10 @@ window.loadSettings = function () {
主题
跟随系统
+
+ 玻璃效果
+
+
屏幕亮度
›
@@ -41,6 +45,7 @@ window.loadSettings = function () {
版本 0.1.0›
`;
setupTheme(page);
+ setupGlass(page);
setupMask(page);
setupNotificationAccess(page);
setupBrightness(page);
@@ -71,6 +76,18 @@ function setupTheme(page) {
};
}
+// 玻璃效果开关:普通半透明 ↔ 液态玻璃(backdrop-filter 磨砂 + 边缘高光)
+// Glass effect toggle: plain translucent vs liquid glass
+function setupGlass(page) {
+ const sw = page.querySelector('#glass-sw');
+ const refresh = () => sw.classList.toggle('on', glass.enabled());
+ refresh();
+ page.querySelector('#glass-item').onclick = () => {
+ glass.setEnabled(!glass.enabled());
+ refresh();
+ };
+}
+
// 背景遮罩:开关 + 明暗度(仅夜间主题显示)
// Wallpaper dim: toggle + opacity (shown only under dark theme)
function setupMask(page) {
diff --git a/app/src/main/assets/h5/js/router.js b/app/src/main/assets/h5/js/router.js
index 0458ad7..6d26fa2 100644
--- a/app/src/main/assets/h5/js/router.js
+++ b/app/src/main/assets/h5/js/router.js
@@ -29,9 +29,16 @@ const router = {
// always occupies space; only toggle visibility to avoid shifting icons)
const clock = document.getElementById('rail-clock');
if (clock) clock.style.visibility = (id === 'home') ? 'hidden' : 'visible';
- if (id === 'home') window.updateHomeCards && window.updateHomeCards();
- if (id === 'applist') window.loadAppList && window.loadAppList();
- if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList();
- if (id === 'settings') window.loadSettings && window.loadSettings();
+ // 延迟内容加载到下一帧:让切换动画先流畅播放,避免加载重活(查应用/拉清单)
+ // 在动画帧内阻塞导致卡顿
+ // Defer content loading to the next frame: let the switch animation play
+ // smoothly first, avoiding jank from heavy loads (app query / manifest fetch)
+ // blocking within the animation frame
+ requestAnimationFrame(() => {
+ if (id === 'home') window.updateHomeCards && window.updateHomeCards();
+ if (id === 'applist') window.loadAppList && window.loadAppList();
+ if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList();
+ if (id === 'settings') window.loadSettings && window.loadSettings();
+ });
}
};
diff --git a/app/src/main/java/top/yeij/hearth/MainActivity.kt b/app/src/main/java/top/yeij/hearth/MainActivity.kt
index bf27ca9..33b55a5 100644
--- a/app/src/main/java/top/yeij/hearth/MainActivity.kt
+++ b/app/src/main/java/top/yeij/hearth/MainActivity.kt
@@ -164,6 +164,10 @@ class MainActivity : Activity() {
// Log when the main frame finishes loading, to help diagnose a black screen
override fun onPageFinished(view: WebView?, url: String?) {
Log.d(TAG, "onPageFinished: url=$url")
+ // H5 就绪后重新拉取一次媒体,修复启动时推送早于页面渲染的时序问题
+ // Re-pull media once H5 is ready: fixes the startup timing where the
+ // media push happens before the page has rendered
+ bridge.refreshMedia()
}
// 主帧加载失败时展示错误页,避免白屏
diff --git a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
index 88a0d45..d7b242c 100644
--- a/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
+++ b/app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt
@@ -68,6 +68,19 @@ class MediaSessionSource(context: Context) {
callback = null
}
+ // 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
+ // Actively pull the current session and notify (without re-registering), for
+ // refreshing the media card once the H5 page is ready
+ fun refresh() {
+ val cb = callback ?: return
+ try {
+ val current = msm.getActiveSessions(listenerComponent).firstOrNull()
+ notifyMedia(current, cb)
+ } catch (e: SecurityException) {
+ Log.w(TAG, "refresh: no notification access: ${e.message}")
+ }
+ }
+
// 从 controller 提取 metadata + playbackState 构造 MediaInfo 并回调
// Build MediaInfo from the controller's metadata + playbackState and invoke the callback
private fun notifyMedia(controller: MediaController?, cb: (MediaInfo?) -> Unit) {
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 1675c0f..4bfbd6f 100644
--- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt
+++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt
@@ -4,6 +4,7 @@ import android.util.Log
import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.card.Card
import top.yeij.hearth.card.CardRepository
+import top.yeij.hearth.media.MediaInfo
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebApp
@@ -283,15 +284,27 @@ class JsBridge(
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null")
// Register media session listener; push callbacks to H5 (push "null" when info is null)
+ private var mediaSourceRef: MediaSessionSource? = null
+
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
- source.start { info ->
- webView.post {
- val json = info?.toJson() ?: "null"
- webView.evaluateJavascript(
- "window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
- null
- )
- }
+ mediaSourceRef = source
+ source.start { info -> pushMedia(info, webView) }
+ }
+
+ // H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
+ // Re-pull media once the H5 page is ready: fixes the startup timing where the
+ // push happens before the page has rendered
+ fun refreshMedia() {
+ mediaSourceRef?.refresh()
+ }
+
+ private fun pushMedia(info: MediaInfo?, webView: android.webkit.WebView) {
+ webView.post {
+ val json = info?.toJson() ?: "null"
+ webView.evaluateJavascript(
+ "window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
+ null
+ )
}
}
}