Compare commits

...

60 Commits

Author SHA1 Message Date
AskaEth 885be2fe85 feat: disable webview long-press menu on launcher pages except inputs 2026-08-17 21:55:37 +08:00
AskaEth 8b9a707cce chore: CSS zoom scale comment cleanup 2026-08-17 21:53:04 +08:00
AskaEth 06377f067f fix: per-app scale via CSS zoom, hide download icon when local version matches 2026-08-17 21:40:06 +08:00
AskaEth 0194a57734 feat: download icon on webapp cards for auto-install 2026-08-17 20:18:39 +08:00
AskaEth a1b65ad4c6 fix: view-level webview scale, close menu on outside tap/page switch 2026-08-17 20:16:32 +08:00
AskaEth 69bd90d23a fix: disable overview mode for per-app scale to take effect 2026-08-17 20:12:21 +08:00
AskaEth fb177cc30f feat: check-update diff report and list refresh 2026-08-17 20:09:15 +08:00
AskaEth 2e0e1d5d47 feat: install-locally button, log delete result 2026-08-17 20:05:39 +08:00
AskaEth 5901ce98e2 feat: add reinstall button to long-press menu 2026-08-17 20:04:16 +08:00
AskaEth bb714d8de3 feat: per-app initial scale config 2026-08-17 20:01:01 +08:00
AskaEth b65a0400a6 fix: allow file access for local offline packages 2026-08-17 19:54:06 +08:00
AskaEth 24c78bb9c2 feat: per-app UA config, fix demo file path and back-loop 2026-08-17 19:51:54 +08:00
AskaEth 5498c8ed0b fix: resolve relative manifest paths, base64 demo icon 2026-08-17 19:44:53 +08:00
AskaEth a35a5018b7 fix: fetch manifest from <server>/manifest.json 2026-08-17 19:41:08 +08:00
AskaEth 2d2dee5e51 fix: allow cleartext HTTP for local/network servers 2026-08-17 19:33:43 +08:00
AskaEth 31107fddd5 feat: log export to Download 2026-08-17 19:25:43 +08:00
AskaEth d9a0342271 fix: add confirm button for server url input 2026-08-17 19:11:37 +08:00
AskaEth 811e2c9716 feat: offline package version management, long-press menu 2026-08-17 18:02:53 +08:00
AskaEth 49b84f83ad feat: offline package download/import, load progress, local-first open 2026-08-17 13:11:32 +08:00
AskaEth 010076ff28 docs: add device debugging and UI iteration log 2026-08-17 12:51:33 +08:00
AskaEth 8d7e015857 fix: cache media cover to avoid vanishing on swipe 2026-08-17 12:49:38 +08:00
AskaEth 17d573e589 fix: visible music-icon placeholder when media has no cover 2026-08-17 12:48:03 +08:00
AskaEth d1d903278e feat: draggable progress bar (seek) and settings sliders 2026-08-17 12:20:05 +08:00
AskaEth 91665bbbd5 fix: restore webapp list visibility on page switch 2026-08-16 21:45:34 +08:00
AskaEth 9fc3272923 feat: native tab panel overlay, webapp height no longer reserved 2026-08-16 21:41:05 +08:00
AskaEth 0154ebc999 fix: increase top reserve so tab panel fully clears content webview 2026-08-16 21:38:01 +08:00
AskaEth b901afc901 fix: horizontal tab panel in top reserve area, no longer covered 2026-08-16 21:35:44 +08:00
AskaEth bc08a8e4c3 fix: hide webapp list content while a webapp is open 2026-08-16 21:33:38 +08:00
AskaEth e0add95d7a fix: webapp topbar spans full content width 2026-08-16 21:28:53 +08:00
AskaEth e34a67aeea fix: media card keeps span-2 after swipe 2026-08-16 21:26:55 +08:00
AskaEth 9f8ca61374 fix: bold time card, tab panel position above content webview 2026-08-16 21:25:37 +08:00
AskaEth 6020b89215 feat: time card font scales to half card short side, centered 2026-08-16 21:22:00 +08:00
AskaEth 1bec58420d fix: progress bar padding, time card stacked layout 2026-08-16 21:18:41 +08:00
AskaEth f3f52030f4 fix: media progress bar initial width and duration labels 2026-08-16 21:15:00 +08:00
AskaEth 729b4ba302 fix: fixed media title height to avoid layout jump on lyrics change 2026-08-16 21:13:06 +08:00
AskaEth 37979119b7 fix: align media controls center, add media duration logging 2026-08-16 21:11:45 +08:00
AskaEth ed8471a9e7 perf: in-place media card update for live lyrics 2026-08-16 21:10:08 +08:00
AskaEth 18a237bd1f feat: live lyrics from notification title, fix topbar overlap 2026-08-16 21:06:08 +08:00
AskaEth fe2dcabdd4 feat: hide foreground webapp when switching sidebar pages 2026-08-16 20:27:17 +08:00
AskaEth 7a28e2372c fix: grid minmax, media polling fallback, content webview layout, swipe timeout 2026-08-16 20:25:21 +08:00
AskaEth 8d10d9e5e9 feat: media card polish - per-index controls, cover jump, wrap, slide transition 2026-08-16 20:13:47 +08:00
AskaEth c82418f846 feat: bundle demo webapp in apk 2026-08-16 20:00:26 +08:00
AskaEth 5f5d0431ee feat: media card cover, controls, live progress, multi-session swipe 2026-08-16 19:59:30 +08:00
AskaEth 8c42dca7bc feat: page preload, card grid span, wallpaper into webview for glass blur 2026-08-16 19:51:08 +08:00
AskaEth 1a79b4ed55 fix: media card survives page switch, cards fill column height 2026-08-16 18:47:29 +08:00
AskaEth 6915c54832 feat: glass effect toggle, media refresh timing fix, page transition perf 2026-08-16 18:42:04 +08:00
AskaEth a65205b8d2 fix: pull active media session on start, icon centering, tap highlight, media card 2026-08-16 18:33:14 +08:00
AskaEth c9dcb88647 feat: settings interactions (theme/mask/brightness/server-url), svg icons, seconds display, page transition 2026-08-16 18:25:28 +08:00
AskaEth e2a289a4b0 feat: add notification-access setting item with jump 2026-08-16 18:12:06 +08:00
AskaEth 8e722dc545 fix: catch SecurityException when notification access not granted 2026-08-16 18:10:26 +08:00
AskaEth 160274e218 fix: enter immersive mode after setContentView to avoid DecorView NPE 2026-08-16 18:08:52 +08:00
AskaEth ebcd771a1f fix: add LAUNCHER category and page-finished log 2026-08-16 17:59:40 +08:00
AskaEth a39264d785 docs: add development log 2026-08-16 17:50:28 +08:00
AskaEth 7ce4df15e6 fix: synchronize webapp container state access 2026-08-16 17:49:38 +08:00
AskaEth f7cffe27d9 fix: final review findings - thread safety, xss, cache 2026-08-16 17:35:13 +08:00
AskaEth a0f8873b7a fix: marshal js bridge view ops to main thread 2026-08-16 17:21:02 +08:00
AskaEth 6ca55dbe5f feat: webapp multi-tab JS bridge and multi-WebView management 2026-08-16 17:13:48 +08:00
AskaEth 381b9a57e7 feat: wire MainActivity with repositories, error page, and back handling 2026-08-16 17:03:07 +08:00
AskaEth abed4f4e15 feat: notification listener service and media source hardening 2026-08-16 17:02:11 +08:00
AskaEth 05a4009532 feat: settings page 2026-08-16 16:56:08 +08:00
33 changed files with 3736 additions and 225 deletions
+11 -1
View File
@@ -1,9 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application <application
android:label="Hearth" android:label="Hearth"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -12,9 +12,19 @@
android:theme="@style/Theme.Hearth"> android:theme="@style/Theme.Hearth">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" /> <category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".media.HearthNotificationListenerService"
android:label="Hearth 媒体监听"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
+584 -50
View File
@@ -1,7 +1,27 @@
/* Hearth 桌面样式:侧边栏 + 5 页框架 + 柔光玻璃容器 */ /* Hearth 桌面样式:侧边栏 + 5 页框架 + 柔光玻璃容器 */
/* Hearth launcher styles: sidebar + 5-page frame + soft-glass containers */ /* Hearth launcher styles: sidebar + 5-page frame + soft-glass containers */
* { margin: 0; padding: 0; box-sizing: border-box; } * {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 移除 WebView 默认蓝色点击高亮,改用下方 :active 的 Miuix 风格反馈 */
/* Remove the default blue tap highlight; use the Miuix-style :active feedback below */
-webkit-tap-highlight-color: transparent;
/* 禁用文本选择,避免长按弹出 WebView 选择/复制菜单(输入框除外) */
/* Disable text selection to avoid the long-press WebView context menu (except inputs) */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* 输入框/文本域/可编辑区保留文本选择,长按可复制粘贴 */
/* Inputs / textareas / editable areas keep text selection for copy/paste */
input, textarea, [contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
html, body { height: 100%; } html, body { height: 100%; }
@@ -9,22 +29,36 @@ html, body { height: 100%; }
/* Wallpaper transparency: html/body keep transparent to reveal native wallpaper */ /* Wallpaper transparency: html/body keep transparent to reveal native wallpaper */
html, body { background: transparent; } html, body { background: transparent; }
/* 背景遮罩层:夜间主题下盖在壁纸上、UI 之下,透明度由 mask.js 控制 */
/* Wallpaper dim overlay: sits above the wallpaper and below the UI; opacity is
controlled by mask.js */
#wallpaper-dim {
position: fixed;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none;
z-index: 0;
transition: opacity .3s;
}
body { body {
color: var(--text); color: var(--text);
font-family: -apple-system, "MiSans", "PingFang SC", sans-serif; font-family: -apple-system, "MiSans", "PingFang SC", sans-serif;
overflow: hidden; overflow: hidden;
} }
.app { display: flex; height: 100vh; } .app { position: relative; z-index: 1; display: flex; height: 100vh; }
/* 侧边栏:柔光玻璃容器,非纯色背景 */ /* 侧边栏:柔光玻璃容器,非纯色背景 */
/* Sidebar: soft-glass container, not a solid color */ /* Sidebar: soft-glass container, not a solid color */
.rail { .rail {
width: 80px; width: 80px;
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1); backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1); -webkit-backdrop-filter: var(--blur-filter);
border-right: 1px solid var(--glass-border); border-right: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -35,12 +69,23 @@ body {
.rail.expanded { width: 240px; align-items: stretch; padding: 12px 8px; } .rail.expanded { width: 240px; align-items: stretch; padding: 12px 8px; }
.rail-clock { font-size: 14px; color: var(--text-dim); display: none; } .rail-clock {
font-size: 14px;
color: var(--text-dim);
height: 18px;
line-height: 18px;
/* 始终占位固定高度,首页隐藏文字但保留高度,避免撑动下方图标 */
/* Always occupy a fixed height; hide text on home but keep the height to
avoid shifting the icons below */
visibility: hidden;
}
.rail-item { .rail-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
width: 100%;
gap: 3px; gap: 3px;
background: none; background: none;
border: none; border: none;
@@ -50,89 +95,233 @@ body {
cursor: pointer; cursor: pointer;
} }
.rail-item .ico { font-size: 20px; } .rail-item .ico {
.rail-item .lbl { font-size: 11px; } width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.rail-item .ico svg { width: 100%; height: 100%; display: block; }
.rail-item .lbl { font-size: 11px; text-align: center; width: 100%; }
.rail-item.active { background: var(--accent); color: #fff; } .rail-item.active { background: var(--accent); color: #fff; }
/* Miuix 风格点击反馈:轻按压时整体轻微变淡,替代默认蓝色高亮 */
/* Miuix-style press feedback: a subtle fade on press instead of the blue highlight */
.rail-item:active,
.cell:active,
.wcell:active,
.st-item:active {
opacity: 0.6;
transition: opacity .12s ease;
}
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; }
.page { display: none; height: 100%; } .page { display: none; height: 100%; }
.page.active { display: flex; flex-direction: column; } .page.active {
display: flex;
flex-direction: column;
/* 页面切换淡入 + 轻微上移动画 */
/* Fade-in + slight rise transition on page switch */
animation: page-fade .22s ease;
}
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */ @keyframes page-fade {
/* Home three-column layout: three equal vertical columns, cards stack downward */ from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* 首页卡片 Grid:三等分列,卡片可跨栏(span-2/3),行均分高度 */
/* 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, minmax(0, 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 {
/* 卡片在栏内自动拉伸填满高度(多卡均分) */
/* Cards stretch to fill the column height (even split when multiple) */
flex: 1 1 0;
min-height: 0;
min-width: 0; /* 防止内容(长标题/歌词)撑开 Grid 列宽 */
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1); backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1); -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 14px; border-radius: 14px;
padding: 20px; padding: 20px;
color: var(--text); color: var(--text);
transition: transform .18s ease;
}
/* 卡片非交互区域的触摸按压反馈:轻微缩放,避免生硬 */
/* Touch/press feedback on the card's non-interactive area: subtle scale */
.card:active {
transform: scale(0.985);
} }
/* 大字时间卡:主时间大字 + 日期副行 */ /* 大字时间卡:主时间大字 + 日期副行 */
/* Big time card: large clock + date subtitle */ /* Big time card: large clock + date subtitle */
.time-card .big { /* 时钟卡缩放权重小:占栏高比例小于其他卡片 */
font-size: 56px; /* Time card grows less: takes a smaller share of column height than other cards */
.time-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
/* 小时 / 分钟:上下两行大字,等宽数字避免宽度抖动;
font-size 由 JS 按「卡片短边一半」动态设置 */
/* Hour / minute: stacked large lines, tabular numerals avoid width jitter;
font-size is set by JS to half the card's short side */
.time-card .h,
.time-card .m {
font-weight: 600;
line-height: 1.05;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* 秒显:小号、弱化,位于分钟下方 */
/* Seconds: small and dim, below the minute */
.time-card .sec {
font-size: 18px;
font-weight: 300; font-weight: 300;
line-height: 1; color: var(--text-dim);
margin-top: 4px;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
} }
.time-card .sub { /* 年月日星期:靠卡片底部,填充高处留白 */
margin-top: 8px; /* Full date: pinned to the card bottom, filling the tall space */
font-size: 14px; .time-card .date-full {
margin-top: auto;
padding-top: 8px;
font-size: 13px;
color: var(--text-dim); color: var(--text-dim);
} }
/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */ /* 媒体卡:flex column,信息区靠上、进度/控制靠下 */
/* Media card: playing caption + title/artist + progress bar */ /* Media card: flex column, info top, progress/controls bottom */
.media-card .cap { .media-card {
font-size: 11px; display: flex;
color: var(--text-dim); flex-direction: column;
letter-spacing: 1px; overflow: hidden;
} }
/* 媒体卡布局:默认上下(封面在上、信息在下),横条时左右(.wide) */
/* Media layout: vertical (cover on top, info below) by default; horizontal
(cover left, info right) when the card is wider than tall (.wide) */
.media-card .media-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex: 1;
min-height: 0;
text-align: center;
}
.media-card.wide .media-layout {
flex-direction: row;
text-align: left;
}
/* 封面:自适应,随卡片变大;可点击跳转播放界面 */
/* Cover: adaptive size, grows with the card; tappable to open the playback UI */
.media-card .cover {
width: 96px;
height: 96px;
max-width: 100%;
border-radius: 16px;
object-fit: cover;
flex: none;
cursor: pointer;
transition: transform .18s ease;
}
.media-card .cover:active {
transform: scale(0.92);
}
.media-card .cover-ph {
background: linear-gradient(135deg, var(--accent), rgba(255, 157, 0, .6));
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.media-card .cover-ph svg {
width: 40%;
height: 40%;
}
.media-card.wide .cover {
width: 120px;
height: 120px;
}
/* 信息区 */
/* Info area */
.media-card .media-info {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* 标题:自动换行(歌词/长标题),最多 3 行省略;
固定 3 行高度避免歌词长短变化导致下方进度条/按钮上下跳动 */
/* Title: wraps automatically (lyrics / long titles), max 3 lines with ellipsis;
fixed 3-line height so the progress/controls below don't jump as lyrics change */
.media-card .tt { .media-card .tt {
margin-top: 6px; margin-top: 6px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
line-height: 1.3;
min-height: 3.9em;
white-space: normal;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.media-card .ar { .media-card .ar {
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;
} }
/* 进度条 */
/* Progress bar */
.media-card .prog { .media-card .prog {
margin-top: 12px; margin-top: auto;
/* 不要用 padding-top:全局 box-sizing:border-box 下 padding 会吃掉 height
导致 .bar 的 height:100% 变成 0 */
/* No padding-top: with box-sizing:border-box the padding eats the height,
making .bar's height:100% zero */
height: 4px; height: 4px;
border-radius: 999px; border-radius: 999px;
background: var(--glass-border); background: var(--glass-border);
@@ -143,8 +332,96 @@ body {
height: 100%; height: 100%;
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
transition: width .3s linear;
} }
/* 时长文字:当前时长 / 总时长 */
/* Time labels: current / total duration */
.media-card .time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
/* 无时长信息时的活动脉冲进度条 */
/* 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 图标 + 按压动画 */
/* Controls: SVG icons + press animation */
.media-card .controls {
margin-top: 12px;
display: flex;
gap: 10px;
justify-content: center;
/* 垂直居中:中间播放按钮直径更大,对齐圆心而非顶部 */
/* Center vertically: the play button is larger, align centers not tops */
align-items: center;
}
.media-card .controls button {
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: var(--glass-border);
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform .15s ease, background .15s ease, opacity .15s ease;
}
.media-card .controls button svg {
width: 20px;
height: 20px;
}
.media-card .controls button.play {
width: 48px;
height: 48px;
background: var(--accent);
color: #fff;
}
.media-card .controls button:active {
transform: scale(0.82);
opacity: 0.7;
}
/* 媒体卡横划切换的方向过渡动画 */
/* Directional slide transitions for the media card swipe */
@keyframes slide-out-left {
to { transform: translateX(-40%); opacity: 0; }
}
@keyframes slide-out-right {
to { transform: translateX(40%); opacity: 0; }
}
@keyframes slide-in-left {
from { transform: translateX(-40%); opacity: 0; }
to { transform: none; opacity: 1; }
}
@keyframes slide-in-right {
from { transform: translateX(40%); opacity: 0; }
to { transform: none; opacity: 1; }
}
.media-card.slide-out-left { animation: slide-out-left .2s ease forwards; }
.media-card.slide-out-right { animation: slide-out-right .2s ease forwards; }
.media-card.slide-in-left { animation: slide-in-left .25s ease; }
.media-card.slide-in-right { animation: slide-in-right .25s ease; }
/* 安卓 APP 列表页:搜索框 + 网格 */ /* 安卓 APP 列表页:搜索框 + 网格 */
/* Android app list page: search box + grid */ /* Android app list page: search box + grid */
.search { padding: 16px; } .search { padding: 16px; }
@@ -167,6 +444,9 @@ body {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 12px; gap: 12px;
padding: 0 16px 16px; padding: 0 16px 16px;
/* 行靠顶部对齐,避免结果只有一行时被拉伸到接近容器高度 */
/* Align rows to the top so a single result row doesn't stretch to container height */
align-content: start;
} }
.cell { .cell {
@@ -216,21 +496,133 @@ body {
color: var(--text-dim); color: var(--text-dim);
} }
/* 下载离线包图标(右下角,云端有离线包时显示) */
/* Download-offline-package icon (bottom-right, shown when cloud has a package) */
.wcell-dl {
position: absolute;
bottom: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wcell-dl svg {
width: 14px;
height: 14px;
}
.wcell-dl:active {
opacity: 0.6;
}
/* webAPP 顶栏:浮层玻璃条 + 标签面板 */ /* webAPP 顶栏:浮层玻璃条 + 标签面板 */
/* webapp topbar: floating glass bar + tab panel */ /* webapp topbar: floating glass bar + tab panel */
/* 前台有 webapp 时隐藏 webapp 列表内容,避免半透明顶栏透出列表控件 */
/* Hide the webapp list content while a webapp is open, so the translucent topbar
doesn't show the list controls through it */
body.webapp-active [data-page="webapplist"] {
visibility: hidden;
}
/* webapp 加载/下载进度覆盖层 */
/* webapp load/download progress overlay */
#webapp-loading {
position: fixed;
inset: 0;
z-index: 200;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .4);
}
#webapp-loading.show { display: flex; }
#webapp-loading .wl-box {
min-width: 220px;
padding: 20px 24px;
border-radius: 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
gap: 12px;
}
#webapp-loading .wl-text {
font-size: 14px;
color: var(--text);
text-align: center;
}
#webapp-loading .wl-bar {
height: 4px;
border-radius: 999px;
background: var(--glass-border);
overflow: hidden;
}
#webapp-loading .wl-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--accent);
transition: width .2s ease;
}
/* webapp 长按管理菜单 */
/* webapp long-press management menu */
.wapp-menu {
position: fixed;
z-index: 300;
min-width: 180px;
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 20px rgba(0, 0, 0, .3);
}
.wapp-menu-item {
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: var(--text);
cursor: pointer;
}
.wapp-menu-item:active {
background: var(--glass-border);
}
#web-topbar { #web-topbar {
position: fixed; position: fixed;
top: 12px; top: 12px;
left: 50%; /* 占满内容 WebView 宽度(侧边栏右侧到屏幕右),遮住下方桌面内容(如搜索框),
transform: translateX(-50%); 避免顶栏两侧露出可点击的桌面元素 */
/* Span the content WebView width (sidebar's right edge to the screen's right)
to cover the desktop content below (e.g. the search box) so no clickable
desktop element peeks through beside the topbar */
left: 84px;
right: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 10px; padding: 6px 10px;
border-radius: 999px; border-radius: 16px;
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2); backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: blur(20px) saturate(1.2); -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
z-index: 100; z-index: 100;
} }
@@ -266,16 +658,21 @@ body {
#tab-panel { #tab-panel {
position: fixed; position: fixed;
top: 56px; /* 顶栏正下方横向展开,位于内容 WebView 的顶部预留区(96dp)内,不被遮挡 */
right: 16px; /* Horizontal row right below the topbar, inside the content WebView's top
reserve (96dp) so it isn't covered */
top: 54px;
left: 84px;
right: 12px;
display: none; display: none;
min-width: 180px; flex-direction: row;
max-width: 260px; gap: 8px;
overflow-x: auto;
padding: 8px; padding: 8px;
border-radius: 16px; border-radius: 16px;
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2); backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: blur(20px) saturate(1.2); -webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
z-index: 100; z-index: 100;
} }
@@ -310,3 +707,140 @@ body {
} }
#tab-panel .tab-close:hover { opacity: 1; } #tab-panel .tab-close:hover { opacity: 1; }
/* 设置页:Miuix Preference 分组 + 柔光玻璃条目 */
/* Settings page: Miuix Preference groups + soft-glass items */
.st-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
/* 分组标题:小号弱化文本 */
/* Group header: small dim text */
.st-group {
margin: 8px 4px 6px;
font-size: 13px;
color: var(--text-dim);
}
/* 设置项:玻璃拟态圆角行(复用 Task 4 token,非纯色背景) */
/* Setting item: soft-glass rounded row (reuse Task 4 tokens, not solid color) */
.st-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
margin-bottom: 8px;
border-radius: 14px;
background: var(--glass-bg);
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);
}
.st-item .lbl { font-size: 15px; }
/* 服务器地址输入框:点击设置项后展开 */
/* Server URL input: expanded inside the item on tap */
.url-input {
flex: 1;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text);
font-size: 13px;
outline: none;
}
/* 服务器地址确认按钮 */
/* Server URL save button */
.url-save {
flex: none;
padding: 6px 14px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 13px;
cursor: pointer;
}
.url-save:active {
opacity: 0.7;
}
/* 右侧箭头:弱化色 */
/* Trailing arrow: dim color */
.st-item .arrow {
color: var(--text-dim);
font-size: 18px;
line-height: 1;
}
/* 开关:Miuix Switch(静态展示,交互留后续) */
/* Switch: Miuix style (static; interaction later) */
.sw {
position: relative;
flex: none;
width: 46px;
height: 28px;
border-radius: 999px;
background: rgba(127, 127, 127, 0.35);
transition: background .2s;
}
.sw.on { background: var(--accent); }
.sw i {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left .2s;
}
.sw.on i { left: 21px; }
/* 滑块:Miuix Slider(静态展示:轨道 + 高亮填充 + 圆点) */
/* Slider: Miuix style (static: track + filled portion + thumb) */
.slider {
position: relative;
flex: none;
width: 140px;
height: 4px;
border-radius: 999px;
background: var(--glass-border);
}
.slider::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--fill, 60%);
border-radius: 999px;
background: var(--accent);
}
.slider i {
position: absolute;
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
+25 -1
View File
@@ -6,10 +6,24 @@
--text: #1a1a1a; --text: #1a1a1a;
--text-dim: #8a8a90; --text-dim: #8a8a90;
--accent: #ff6900; --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);
}
/* 系统深色:仅在未强制浅色时生效(跟随系统) */
/* System dark: only applies when not forced light (follow-system) */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--glass-bg: rgba(21, 21, 24, 0.55); /* 深色半透明 / dark translucent */ --glass-bg: rgba(21, 21, 24, 0.55); /* 深色半透明 / dark translucent */
--glass-border: rgba(255, 255, 255, 0.08); --glass-border: rgba(255, 255, 255, 0.08);
--text: #ffffff; --text: #ffffff;
@@ -17,3 +31,13 @@
--accent: #ff6900; --accent: #ff6900;
} }
} }
/* 强制深色:无论系统主题 */
/* Forced dark: regardless of system theme */
:root[data-theme="dark"] {
--glass-bg: rgba(21, 21, 24, 0.55);
--glass-border: rgba(255, 255, 255, 0.08);
--text: #ffffff;
--text-dim: #9a9aa0;
--accent: #ff6900;
}
+40
View File
@@ -6,8 +6,29 @@
<title>Hearth</title> <title>Hearth</title>
<link rel="stylesheet" href="css/tokens.css"> <link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
<script src="js/theme.js"></script>
<script src="js/glass.js"></script>
<script>
// 阻止非输入框的 WebView 长按菜单(文本/图片的复制、保存等),
// 输入框/文本域/可编辑区保留系统菜单以便复制粘贴
// Block the WebView long-press context menu on non-input elements (copy/save
// image); keep it for inputs/textareas/contenteditable for copy/paste
document.addEventListener('contextmenu', function (e) {
var t = e.target;
var tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable) return;
e.preventDefault();
}, true);
</script>
</head> </head>
<body> <body>
<div id="wallpaper-dim"></div>
<div id="webapp-loading">
<div class="wl-box">
<div class="wl-text">加载中…</div>
<div class="wl-bar"><div class="wl-fill"></div></div>
</div>
</div>
<div class="app"> <div class="app">
<aside class="rail" id="rail"> <aside class="rail" id="rail">
<div class="rail-clock" id="rail-clock">14:30</div> <div class="rail-clock" id="rail-clock">14:30</div>
@@ -22,11 +43,14 @@
</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/cards/cards.js"></script> <script src="js/cards/cards.js"></script>
<script src="js/router.js"></script> <script src="js/router.js"></script>
<script src="js/pages/applist.js"></script> <script src="js/pages/applist.js"></script>
<script src="js/pages/webapplist.js"></script> <script src="js/pages/webapplist.js"></script>
<script src="js/pages/home.js"></script> <script src="js/pages/home.js"></script>
<script src="js/pages/settings.js"></script>
<script src="js/webapp/tabs.js"></script> <script src="js/webapp/tabs.js"></script>
<script src="js/webapp/topbar.js"></script> <script src="js/webapp/topbar.js"></script>
<script> <script>
@@ -47,6 +71,22 @@
document.getElementById('rail-clock').textContent = `${hh}:${mm}`; document.getElementById('rail-clock').textContent = `${hh}:${mm}`;
}, 10000); }, 10000);
router.navigate('home'); router.navigate('home');
// 初始化背景遮罩(夜间主题 + 已启用时生效)
// 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> </script>
</body> </body>
</html> </html>
+24
View File
@@ -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();
})();
+40
View File
@@ -0,0 +1,40 @@
// 背景遮罩:夜间主题下给壁纸加暗色遮罩,可开关 + 调明暗度(存 localStorage
// Wallpaper dim: darkens the wallpaper under dark theme, with toggle + opacity
// control (persisted in localStorage)
(function () {
const KEY_ON = 'hearth-mask-enabled';
const KEY_ALPHA = 'hearth-mask-alpha';
// 判断当前是否深色主题(强制深色,或跟随系统且系统为深色)
// Whether the current theme is dark (forced dark, or follow-system + system dark)
function isDark() {
const t = window.theme.get();
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
window.mask = {
enabled() {
return localStorage.getItem(KEY_ON) === '1';
},
alpha() {
return parseFloat(localStorage.getItem(KEY_ALPHA) || '0.55');
},
setEnabled(on) {
localStorage.setItem(KEY_ON, on ? '1' : '0');
this.apply();
},
setAlpha(a) {
localStorage.setItem(KEY_ALPHA, String(a));
this.apply();
},
// 应用遮罩:仅深色主题且启用时显示(透明度 = 明暗度)
// Apply: show only under dark theme when enabled (opacity = alpha)
apply() {
const el = document.getElementById('wallpaper-dim');
if (!el) return;
el.style.opacity = (isDark() && this.enabled()) ? String(this.alpha()) : '0';
},
};
})();
+21 -7
View File
@@ -6,15 +6,29 @@ 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.innerHTML = list.map(a => ` grid.textContent = '';
<button class="cell" data-pkg="${a.packageName}"> // 用 createElement + textContent/dataset 渲染,避免 innerHTML 拼接用户数据(XSS
<img class="ic" src="${a.iconBase64 || ''}" alt=""> // Render with createElement + textContent/dataset to avoid innerHTML user data (XSS)
<span class="lbl">${a.label}</span> list.forEach(a => {
</button>`).join(''); const btn = document.createElement('button');
grid.querySelectorAll('.cell').forEach(el => el.onclick = () => bridge.call('launchApp', el.dataset.pkg)); btn.className = 'cell';
btn.dataset.pkg = a.packageName;
const img = document.createElement('img');
img.className = 'ic';
img.src = a.iconBase64 || '';
img.alt = '';
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = a.label;
btn.append(img, lbl);
btn.onclick = () => bridge.call('launchApp', btn.dataset.pkg);
grid.appendChild(btn);
});
}; };
render(apps); render(apps);
document.getElementById('app-search').oninput = (e) => { document.getElementById('app-search').oninput = (e) => {
+301 -44
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,65 @@ 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));
});
});
startTimeCardTicker(); startTimeCardTicker();
}; };
// 内置大字时间卡:当前时间 + 日期 // 卡片 Grid 渲染:media 卡 span 2(跨栏),time/其他卡 span 1
// Builtin big time card: current time + date // 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 (currentMediaList && currentMediaList.length > 0) {
items.push({ span: 2, el: renderMediaCard(currentMediaList[currentMediaIndex]) });
}
// 其他卡(排除 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: hour/minute stacked large + small seconds + full date
function renderTimeCard() { function renderTimeCard() {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'card time-card'; el.className = 'card time-card';
const now = new Date(); const now = new Date();
el.innerHTML = `<div class="big">${fmtTime(now)}</div><div class="sub">${fmtDate(now)}</div>`; el.innerHTML = `
<div class="h">${fmtHour(now)}</div>
<div class="m">${fmtMinute(now)}</div>
<div class="sec">${fmtSec(now)}</div>
<div class="date-full">${fmtFullDate(now)}</div>`;
applyTimeCardSize(el);
return el; return el;
} }
// 时间卡字号自适应:HH/MM 字号 = 卡片短边的一半,随卡片尺寸变化
// Adaptive time-card font: HH/MM font-size = half the card's short side,
// tracking the card size
function applyTimeCardSize(el) {
const check = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (w <= 0 || h <= 0) return;
const size = Math.floor(Math.min(w, h) / 2);
el.querySelectorAll('.h, .m').forEach(n => { n.style.fontSize = size + 'px'; });
};
check();
requestAnimationFrame(check);
}
// 其他卡片占位:仅显示 id,后续 Task 接具体卡片渲染 // 其他卡片占位:仅显示 id,后续 Task 接具体卡片渲染
// Other cards placeholder: show id only; concrete renderers land in later tasks // Other cards placeholder: show id only; concrete renderers land in later tasks
function renderGenericCard(id) { function renderGenericCard(id) {
@@ -50,19 +88,25 @@ function renderGenericCard(id) {
return el; return el;
} }
function fmtTime(d) { function fmtHour(d) {
const hh = String(d.getHours()).padStart(2, '0'); return String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
} }
function fmtDate(d) { function fmtMinute(d) {
return String(d.getMinutes()).padStart(2, '0');
}
function fmtSec(d) {
return String(d.getSeconds()).padStart(2, '0');
}
function fmtFullDate(d) {
const days = ['日', '一', '二', '三', '四', '五', '六']; const days = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getMonth() + 1}${d.getDate()}日 周${days[d.getDay()]}`; return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 周${days[d.getDay()]}`;
} }
// 每 10s 刷新一次大字时间卡 // 每刷新一次大字时间卡(含秒显)
// Refresh the big time card every 10 seconds // Refresh the big time card every second (including the seconds display)
let timeTicker = null; let timeTicker = null;
function startTimeCardTicker() { function startTimeCardTicker() {
if (timeTicker) return; if (timeTicker) return;
@@ -70,42 +114,255 @@ function startTimeCardTicker() {
const card = document.querySelector('.time-card'); const card = document.querySelector('.time-card');
if (!card) return; if (!card) return;
const now = new Date(); const now = new Date();
card.querySelector('.big').textContent = fmtTime(now); card.querySelector('.h').textContent = fmtHour(now);
card.querySelector('.sub').textContent = fmtDate(now); card.querySelector('.m').textContent = fmtMinute(now);
}, 10000); card.querySelector('.sec').textContent = fmtSec(now);
card.querySelector('.date-full').textContent = fmtFullDate(now);
}, 1000);
} }
// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局 // 当前媒体会话列表(多个:音乐/听书/视频)+ 当前展示索引
// Native media session event: render media card in middle column when present, // Current media session list (multiple: music/audiobook/video) + shown index
// otherwise fall back to re-layout (calendar/weather fill the media card slot) let currentMediaList = null;
let currentMediaIndex = 0;
// SVG 控制图标(fill=currentColor,替代 emoji
// SVG control icons (fill=currentColor, replacing emoji)
const ICON_PREV = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6l-8 6 8 6V6z"/></svg>';
const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7L8 5z"/></svg>';
const ICON_PAUSE = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>';
const ICON_NEXT = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M4 6l8 6-8 6V6z"/></svg>';
// 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
// Native media session event: receives the session list; render media card when
// present, otherwise fall back to re-layout
window.HearthEvents = window.HearthEvents || {}; window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (info) { window.HearthEvents.mediaSessionChanged = function (infos) {
const mediaCol = document.getElementById('col-1'); if (!infos || infos.length === 0) {
if (!mediaCol) return; currentMediaList = null;
if (info) { currentMediaIndex = 0;
mediaCol.innerHTML = ''; mediaProgressState = null;
mediaCol.appendChild(renderMediaCard(info));
} else { } else {
// 无媒体:降级到日历/天气(由 cards.layout 重新计算) currentMediaList = infos;
// No media: fall back to calendar/weather (recomputed by cards.layout) if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
window.updateHomeCards(); updateMediaProgressState(infos[currentMediaIndex]);
} }
const grid = document.getElementById('tri-col');
if (!grid) return;
// 若媒体卡已存在且还有媒体,增量更新(歌词实时变化时避免重建整个首页导致卡顿)
// If the media card exists and media is still active, update in place (avoid
// rebuilding the whole home on every live-lyrics change, which causes jank)
const card = grid.querySelector('.media-card');
if (card && currentMediaList && currentMediaList.length > 0) {
updateMediaCardInPlace(card, currentMediaList[currentMediaIndex]);
return;
}
window.updateHomeCards();
}; };
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 // 增量更新媒体卡内容(标题/艺术家/封面/播放图标),不重建整个首页
// Media card: playing caption + title/artist + progress bar // Update the media card in place (title/artist/cover/play icon) without rebuilding
function updateMediaCardInPlace(card, info) {
const tt = card.querySelector('.tt');
if (tt) tt.textContent = info.title;
const ar = card.querySelector('.ar');
if (ar) ar.textContent = info.artist;
const cover = card.querySelector('.cover');
if (cover && info.cover) cover.src = info.cover;
const playBtn = card.querySelector('[data-act="play"]');
if (playBtn) playBtn.innerHTML = info.playing ? ICON_PAUSE : ICON_PLAY;
}
// 媒体卡:封面 + 标题/艺术家 + 进度条 + 控制按钮 + 多会话滑动切换
// Media card: cover + title/artist + progress + controls + multi-session swipe
function renderMediaCard(info) { function renderMediaCard(info) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'card media-card'; el.className = 'card media-card';
const pct = info.duration ? (info.position / info.duration) * 100 : 0; const coverHtml = info.cover
? `<img class="cover" src="${info.cover}" alt="">`
: '<div class="cover cover-ph"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V6l12-2v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></div>';
const playIcon = info.playing ? ICON_PAUSE : ICON_PLAY;
// title/artist 来自任意应用元数据,转义后插入,防 XSS // title/artist 来自任意应用元数据,转义后插入,防 XSS
// title/artist come from arbitrary app metadata; escape before insert (XSS) // title/artist come from arbitrary app metadata; escape before insert (XSS)
el.innerHTML = `<div class="cap">正在播放</div> const pct = info.duration ? Math.min(100, (info.position / info.duration) * 100) : 0;
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div> el.innerHTML = `
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`; <div class="media-layout">
${coverHtml}
<div class="media-info">
<div class="tt">${escapeHtml(info.title)}</div>
<div class="ar">${escapeHtml(info.artist)}</div>
</div>
</div>
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>
<div class="time-row">
<span class="t-cur">${fmtDuration(info.position)}</span>
<span class="t-total">${fmtDuration(info.duration)}</span>
</div>
<div class="controls">
<button class="ctl" data-act="prev" title="上一首">${ICON_PREV}</button>
<button class="ctl play" data-act="play" title="播放/暂停">${playIcon}</button>
<button class="ctl" data-act="next" title="下一首">${ICON_NEXT}</button>
</div>`;
// 绑定控制按钮(传入当前索引,作用于当前滑到的会话)
// Bind the controls (pass the current index, act on the shown session)
el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious', currentMediaIndex);
el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause', currentMediaIndex);
el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext', currentMediaIndex);
// 封面点击跳转到对应媒体 App 的播放界面
// Cover tap opens the media app's playback UI
const coverEl = el.querySelector('.cover');
if (coverEl) coverEl.onclick = () => bridge.call('openMediaApp', currentMediaIndex);
// 进度条拖动 seek
setupMediaSeek(el);
// 进度状态 + 全局 ticker
updateMediaProgressState(info);
startMediaTicker();
// 多会话滑动切换
setupMediaSwipe(el);
// 布局方向(上下 / 左右)
applyMediaLayout(el);
return el; return el;
} }
// 全局进度状态:独立于卡片重建,进度条不因重新渲染而"跳回"
// Global progress state: independent of card rebuild, so the bar doesn't reset on re-render
let mediaProgressState = null;
function updateMediaProgressState(info) {
mediaProgressState = {
position: info.position || 0,
duration: info.duration || 0,
playing: !!info.playing,
updatedAt: Date.now(),
};
}
let mediaTicker = null;
function startMediaTicker() {
if (mediaTicker) return;
const tick = () => {
if (!mediaProgressState) return;
const bar = document.querySelector('.media-card .bar');
const cur = document.querySelector('.media-card .t-cur');
const { position, duration, playing, updatedAt } = mediaProgressState;
if (!duration) {
// 无时长信息(部分 App 不提供 duration):显示活动脉冲而非空白
// No duration (some apps omit it): show an indeterminate pulse instead of blank
if (bar) bar.classList.add('indeterminate');
return;
}
const pos = playing ? position + (Date.now() - updatedAt) : position;
if (bar) {
bar.classList.remove('indeterminate');
bar.style.width = Math.min(100, (pos / duration) * 100) + '%';
}
if (cur) cur.textContent = fmtDuration(pos);
};
tick(); // 立即执行一次,避免初始 1 秒内进度条空白
mediaTicker = setInterval(tick, 1000);
}
// 时长格式化:毫秒 -> m:ss(未知返回 --:--
// Duration format: ms -> m:ss (--:-- when unknown)
function fmtDuration(ms) {
if (!ms || ms <= 0) return '--:--';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
// 进度条拖动 seek:拖动预览进度,松手后 seek 到目标位置
// Progress-bar drag seek: preview while dragging, seek on release
function setupMediaSeek(el) {
const prog = el.querySelector('.prog');
if (!prog) return;
let dragging = false;
const ratioAt = (clientX) => {
const rect = prog.getBoundingClientRect();
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
const preview = (clientX) => {
const bar = el.querySelector('.bar');
if (bar) bar.style.width = (ratioAt(clientX) * 100) + '%';
};
prog.addEventListener('touchstart', (e) => {
dragging = true;
preview(e.touches[0].clientX);
}, { passive: true });
prog.addEventListener('touchmove', (e) => {
if (dragging) preview(e.touches[0].clientX);
}, { passive: true });
prog.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
const ratio = ratioAt(e.changedTouches[0].clientX);
const duration = mediaProgressState ? mediaProgressState.duration : 0;
if (duration > 0) {
const pos = Math.floor(ratio * duration);
bridge.call('mediaSeekTo', currentMediaIndex, pos);
if (mediaProgressState) {
mediaProgressState.position = pos;
mediaProgressState.updatedAt = Date.now();
}
}
}, { passive: true });
}
// 布局方向:默认上下(封面在上、信息在下),横条(宽>高)时左右
// Layout direction: vertical (cover on top, info below) by default; horizontal
// (cover left, info right) when the card is wider than tall
function applyMediaLayout(el) {
const check = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (w > 0 && h > 0) el.classList.toggle('wide', w > h);
};
check();
requestAnimationFrame(check);
}
// 多会话滑动切换(左右滑动切换媒体会话)
// Multi-session swipe (swipe left/right to switch media sessions)
function setupMediaSwipe(el) {
let startX = 0;
el.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
}, { passive: true });
el.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - startX;
if (Math.abs(dx) < 60) return;
switchMedia(dx < 0 ? 1 : -1);
}, { passive: true });
}
// 切换当前媒体会话
// Switch the current media session
function switchMedia(delta) {
if (!currentMediaList || currentMediaList.length <= 1) return;
currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length;
const info = currentMediaList[currentMediaIndex];
updateMediaProgressState(info);
const grid = document.getElementById('tri-col');
if (!grid) return;
const old = grid.querySelector('.media-card');
if (!old) return;
// 滑动方向过渡:左滑(下一个)旧卡左出、新卡右入;右滑反之
// Directional transition: swipe left (next) slides old out left and new in from
// the right; swipe right (prev) reverses it
const outClass = delta > 0 ? 'slide-out-left' : 'slide-out-right';
const inClass = delta > 0 ? 'slide-in-right' : 'slide-in-left';
old.classList.add(outClass);
// 用 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);
fresh.classList.add('span-2'); // 保持跨两栏(renderMediaCard 自身不带 span 类)
fresh.classList.add(inClass);
old.replaceWith(fresh);
}, 180);
}
// 转义 HTML 特殊字符,避免媒体元数据注入标签 // 转义 HTML 特殊字符,避免媒体元数据注入标签
// Escape HTML special chars to prevent metadata injection // Escape HTML special chars to prevent metadata injection
function escapeHtml(s) { function escapeHtml(s) {
+282
View File
@@ -0,0 +1,282 @@
// 设置页:Miuix Preference 分组 + 玻璃条目,含主题/遮罩/亮度/权限/网络交互
// Settings page: Miuix Preference groups + glass items with interactions for
// theme / mask / brightness / permission / network
window.loadSettings = function () {
const page = document.querySelector('[data-page="settings"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `<div class="st-wrap">
<div class="st-group">显示</div>
<div class="st-item" id="theme-item">
<span class="lbl">主题</span>
<span class="arrow" id="theme-state">跟随系统</span>
</div>
<div class="st-item" id="glass-item">
<span class="lbl">玻璃效果</span>
<span class="sw" id="glass-sw"><i></i></span>
</div>
<div class="st-item" id="brightness-item">
<span class="lbl">屏幕亮度</span>
<span class="arrow" id="brightness-state"></span>
</div>
<div class="st-group" id="mask-group" style="display:none">背景遮罩</div>
<div class="st-item" id="mask-item" style="display:none">
<span class="lbl">启用背景遮罩</span>
<span class="sw" id="mask-sw"><i></i></span>
</div>
<div class="st-item" id="mask-alpha-item" style="display:none">
<span class="lbl">遮罩明暗度</span>
<span class="slider" id="mask-slider"><i></i></span>
</div>
<div class="st-group">权限</div>
<div class="st-item" id="notif-access">
<span class="lbl">通知使用权(媒体卡)</span>
<span class="arrow" id="notif-access-state"></span>
</div>
<div class="st-group">网络</div>
<div class="st-item" id="server-url-item">
<span class="lbl">webAPP 服务器地址</span>
<span class="arrow" id="server-url-state"></span>
</div>
<div class="st-item" id="check-update-item">
<span class="lbl">检查更新</span>
<span class="arrow" id="check-update-state"></span>
</div>
<div class="st-group">关于</div>
<div class="st-item" id="export-log-item">
<span class="lbl">导出日志</span>
<span class="arrow" id="export-log-state"></span>
</div>
<div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div>
</div>`;
setupTheme(page);
setupGlass(page);
setupMask(page);
setupNotificationAccess(page);
setupBrightness(page);
setupServerUrl(page);
setupCheckUpdate(page);
setupExportLog(page);
};
// 判断当前是否深色主题(与 mask.js 逻辑一致)
// Whether the current theme is dark (same logic as mask.js)
function isDarkTheme() {
const t = theme.get();
if (t === 'dark') return true;
if (t === 'light') return false;
return matchMedia('(prefers-color-scheme: dark)').matches;
}
// 主题三态循环:跟随系统 → 浅色 → 深色
// Theme tri-state cycle: system → light → dark
function setupTheme(page) {
const state = page.querySelector('#theme-state');
const refresh = () => { state.textContent = theme.label(theme.get()); };
refresh();
page.querySelector('#theme-item').onclick = () => {
theme.cycle();
refresh();
window.mask.apply();
setupMask(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) {
const group = page.querySelector('#mask-group');
const maskItem = page.querySelector('#mask-item');
const alphaItem = page.querySelector('#mask-alpha-item');
const sw = page.querySelector('#mask-sw');
const slider = page.querySelector('#mask-slider');
const thumb = slider.querySelector('i');
const dark = isDarkTheme();
const show = dark;
group.style.display = show ? '' : 'none';
maskItem.style.display = show ? '' : 'none';
alphaItem.style.display = show ? '' : 'none';
const refresh = () => {
sw.classList.toggle('on', window.mask.enabled());
const a = window.mask.alpha();
thumb.style.left = (a * 100) + '%';
slider.style.setProperty('--fill', (a * 100) + '%');
};
refresh();
sw.onclick = () => {
window.mask.setEnabled(!window.mask.enabled());
refresh();
};
makeSliderDraggable(slider, (a) => {
window.mask.setAlpha(Math.round(a * 100) / 100);
refresh();
});
}
// 通知使用权授权项:异步查状态,未授权点击跳转系统授权页
// Notification-access item: async state check, tap to open the system grant page
function setupNotificationAccess(page) {
const item = page.querySelector('#notif-access');
const state = page.querySelector('#notif-access-state');
bridge.call('getNotificationAccess').then(granted => {
state.textContent = granted ? '已开启 ✓' : '去开启 ';
if (granted) state.style.color = 'var(--accent)';
}).catch(() => {});
item.onclick = () => {
bridge.call('requestNotificationAccess').catch(() => {});
};
}
// 屏幕亮度:未授权「修改系统设置」时跳授权页,已授权点击滑块调整系统亮度
// Screen brightness: jump to grant page when lacking WRITE_SETTINGS; adjust via
// slider click once granted
function setupBrightness(page) {
const item = page.querySelector('#brightness-item');
const state = page.querySelector('#brightness-state');
let sliderEl = null;
const renderSlider = (value) => {
if (sliderEl) sliderEl.remove();
sliderEl = document.createElement('span');
sliderEl.className = 'slider brightness-slider';
sliderEl.innerHTML = '<i></i>';
sliderEl.style.width = '160px';
const pct = value / 255 * 100;
sliderEl.style.setProperty('--fill', pct + '%');
sliderEl.querySelector('i').style.left = pct + '%';
makeSliderDraggable(sliderEl, (ratio) => {
const v = Math.round(ratio * 255);
bridge.call('setSystemBrightness', v).then(() => renderSlider(v)).catch(() => {});
});
item.appendChild(sliderEl);
};
const refresh = () => {
bridge.call('canWriteSettings').then(canWrite => {
if (!canWrite) {
state.textContent = '去授权 ';
if (sliderEl) { sliderEl.remove(); sliderEl = null; }
} else {
bridge.call('getSystemBrightness').then(v => {
state.textContent = '';
renderSlider(v);
}).catch(() => {});
}
}).catch(() => {});
};
refresh();
item.onclick = () => {
bridge.call('canWriteSettings').then(canWrite => {
if (!canWrite) bridge.call('requestWriteSettings');
}).catch(() => {});
};
}
// webAPP 服务器地址:点击展开输入框,保存后重新拉取清单
// webAPP server URL: expand an input on tap, re-fetch manifest after saving
function setupServerUrl(page) {
const item = page.querySelector('#server-url-item');
const state = page.querySelector('#server-url-state');
let editing = false;
const startEdit = (current) => {
editing = true;
item.innerHTML = '<input class="url-input" id="server-url-input" value="' + (current || '').replace(/"/g, '&quot;') + '">' +
'<button class="url-save" id="server-url-save">确认</button>';
const input = item.querySelector('#server-url-input');
const saveBtn = item.querySelector('#server-url-save');
input.focus();
const save = () => {
const v = input.value.trim();
if (!v) return;
bridge.call('setServerUrl', v).then(() => {
editing = false;
refresh();
}).catch(() => {});
};
saveBtn.onclick = save;
input.onkeydown = (e) => { if (e.key === 'Enter') save(); };
};
const refresh = () => {
bridge.call('getServerUrl').then(url => {
state.textContent = (url || '未设置') + ' ';
}).catch(() => {});
};
refresh();
item.onclick = () => {
if (editing) return;
bridge.call('getServerUrl').then(startEdit).catch(() => startEdit(''));
};
}
// 检查更新:触发重新拉取 webAPP 清单 + 卡片目录,完成后提示
// Check update: re-fetch the webAPP manifest + card catalog, then show a hint
function setupCheckUpdate(page) {
const item = page.querySelector('#check-update-item');
const state = page.querySelector('#check-update-state');
item.onclick = () => {
state.textContent = '检查中…';
bridge.call('refreshManifest').then(result => {
let r = { added: 0, removed: 0, changed: 0 };
try { r = JSON.parse(result); } catch (e) {}
const parts = [];
if (r.added > 0) parts.push('新增 ' + r.added);
if (r.removed > 0) parts.push('移除 ' + r.removed);
if (r.changed > 0) parts.push('更新 ' + r.changed);
state.textContent = parts.length ? parts.join('') + ' ' : '无更新 ';
}).catch(() => {
state.textContent = '失败 ';
});
};
}
// 导出日志:点击导出到 Download,显示结果
// Export log: tap to export to Download, show the result
function setupExportLog(page) {
const item = page.querySelector('#export-log-item');
const state = page.querySelector('#export-log-state');
item.onclick = () => {
state.textContent = '导出中…';
bridge.call('exportLog').then(result => {
state.textContent = result + ' ';
}).catch(() => {
state.textContent = '失败 ';
});
};
}
// 通用滑块拖动:touch 拖动实时回调(保留 click 兼容桌面调试)
// Generic draggable slider: touch-drag with live callback (click kept for desktop)
function makeSliderDraggable(slider, onRatio) {
let dragging = false;
const ratioAt = (clientX) => {
const rect = slider.getBoundingClientRect();
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
slider.addEventListener('touchstart', (e) => {
dragging = true;
onRatio(ratioAt(e.touches[0].clientX));
}, { passive: true });
slider.addEventListener('touchmove', (e) => {
if (dragging) onRatio(ratioAt(e.touches[0].clientX));
}, { passive: true });
slider.addEventListener('touchend', () => { dragging = false; }, { passive: true });
slider.addEventListener('click', (e) => onRatio(ratioAt(e.clientX)));
}
+191 -10
View File
@@ -1,21 +1,56 @@
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 // H5 应用列表页:富卡片 + 在线/离线/可更新标签 + 搜索 + 长按菜单(更新/删除/导入/属性)
// H5 web app list page: rich cards + online/offline badge + search // H5 web app list page: rich cards + online/offline/updatable badge + search +
// long-press menu (update/delete/import/properties)
window.loadWebAppList = async function () { window.loadWebAppList = async function () {
const page = document.querySelector('[data-page="webapplist"]'); const page = document.querySelector('[data-page="webapplist"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1'; if (page.dataset.loaded) return; page.dataset.loaded = '1';
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.innerHTML = list.map(a => ` grid.textContent = '';
<button class="wcell" data-id="${a.id}" data-url="${a.url}"> list.forEach(a => {
<img class="ic" src="${a.icon}" alt=""> const btn = document.createElement('button');
<span class="lbl">${a.name}</span> btn.className = 'wcell';
<span class="tag">${a.offline ? '离线' : '在线'}</span> btn.dataset.id = a.id;
</button>`).join(''); btn.dataset.url = a.url;
grid.querySelectorAll('.wcell').forEach(el => el.onclick = () => bridge.call('openWebApp', el.dataset.id)); const img = document.createElement('img');
img.className = 'ic';
img.src = a.icon;
img.alt = '';
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = a.name;
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = a.offline ? '离线' : '在线';
btn.append(img, lbl, tag);
// 云端有离线包 → 加下载图标(默认隐藏,异步按本地状态决定是否显示)
// Cloud has an offline package -> add a download icon (hidden by default,
// shown based on local state asynchronously)
let dl = null;
if (a.offline) {
dl = document.createElement('span');
dl.className = 'wcell-dl';
dl.title = '下载离线包';
dl.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
dl.style.display = 'none';
dl.onclick = (e) => {
e.stopPropagation();
bridge.call('updateOfflinePackage', a.id).then(() => refreshList()).catch(() => {});
};
btn.appendChild(dl);
}
btn.onclick = () => bridge.call('openWebApp', btn.dataset.id);
setupLongPress(btn, a);
grid.appendChild(btn);
// 异步检查本地离线包状态 + 版本对比,更新标签
refreshBadge(tag, a, dl);
});
}; };
render(apps); render(apps);
document.getElementById('web-search').oninput = (e) => { document.getElementById('web-search').oninput = (e) => {
@@ -23,3 +58,149 @@ window.loadWebAppList = async function () {
render(apps.filter(a => a.name.toLowerCase().includes(kw))); render(apps.filter(a => a.name.toLowerCase().includes(kw)));
}; };
}; };
// 异步刷新列表项标签(离线 / 可更新)+ 下载图标显隐
// Refresh the badge asynchronously (offline / updatable) + download icon visibility
async function refreshBadge(tag, app, dl) {
try {
const hasLocal = await bridge.call('hasLocalPackage', app.id);
const localVer = await bridge.call('getLocalVersion', app.id);
const cloudVer = app.offlineVersion || '';
const updatable = hasLocal && cloudVer && cloudVer !== localVer;
// 下载图标仅在「未安装且有离线包」或「有可更新版本」时显示
// Download icon shows only when there's no local package but cloud has one,
// or when a newer version is available
const needDownload = app.offline && (!hasLocal || updatable);
if (updatable) {
tag.textContent = '可更新';
} else if (hasLocal) {
tag.textContent = '离线';
} else if (app.offline) {
tag.textContent = '离线';
} else {
tag.textContent = '在线';
}
if (dl) dl.style.display = needDownload ? '' : 'none';
} catch (e) { /* 忽略 */ }
}
// 长按(600ms)弹出管理菜单
// Long-press (600ms) to show the management menu
function setupLongPress(btn, app) {
let timer = null;
const start = (e) => {
timer = setTimeout(() => {
showWebAppMenu(app, e.touches[0].clientX, e.touches[0].clientY);
}, 600);
};
const cancel = () => { if (timer) clearTimeout(timer); };
btn.addEventListener('touchstart', start, { passive: true });
btn.addEventListener('touchend', cancel, { passive: true });
btn.addEventListener('touchmove', cancel, { passive: true });
}
// 弹出管理菜单:更新 / 删除 / 导入 / 属性
// Show the management menu: update / delete / import / properties
async function showWebAppMenu(app, x, y) {
document.querySelectorAll('.wapp-menu').forEach(m => m.remove());
const hasLocal = await bridge.call('hasLocalPackage', app.id);
const localVer = await bridge.call('getLocalVersion', app.id);
const cloudVer = app.offlineVersion || '';
const menu = document.createElement('div');
menu.className = 'wapp-menu';
const addItem = (label, onClick) => {
const item = document.createElement('div');
item.className = 'wapp-menu-item';
item.textContent = label;
item.onclick = () => { menu.remove(); if (onClick) onClick(); };
menu.appendChild(item);
};
// 更新(本地有包 + 云端版本不同)
if (hasLocal && cloudVer && cloudVer !== localVer) {
addItem('更新离线包(' + (localVer || '?') + ' → ' + cloudVer + '', () => {
bridge.call('updateOfflinePackage', app.id).catch(() => {});
});
}
// 重装(本地有包 + 云端有离线包:强制删除重下,无论版本)
// Reinstall (local + cloud offline package: force delete and re-download, any version)
if (hasLocal && app.offline) {
addItem('重装离线包', () => {
bridge.call('updateOfflinePackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 删除(本地有包)
if (hasLocal) {
addItem('删除本地包', () => {
bridge.call('deleteLocalPackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 安装到本地(本地无包 + 云端有离线包:预下载到本地,不打开)
// Install locally (no local package + cloud offline package: pre-download without opening)
if (!hasLocal && app.offline) {
addItem('安装到本地', () => {
bridge.call('updateOfflinePackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 导入
addItem('导入离线包', () => {
bridge.call('importOfflinePackage', app.id).catch(() => {});
});
// 属性
addItem('属性:本地 ' + (localVer || '无') + ' / 云端 ' + (cloudVer || '无'), null);
menu.style.left = Math.min(x, window.innerWidth - 220) + 'px';
menu.style.top = Math.min(y, window.innerHeight - 180) + 'px';
document.body.appendChild(menu);
}
// 刷新列表(删除/导入后重新加载)
// Refresh the list (reload after delete/import)
function refreshList() {
const page = document.querySelector('[data-page="webapplist"]');
if (page) {
page.dataset.loaded = '';
if (page.classList.contains('active')) window.loadWebAppList();
}
}
// webapp 加载/下载进度:显示覆盖层
// webapp load/download progress: show the overlay
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.webappProgress = function (id, progress) {
const loading = document.getElementById('webapp-loading');
if (!loading) return;
if (progress >= 100) {
loading.classList.remove('show');
return;
}
loading.classList.add('show');
loading.querySelector('.wl-fill').style.width = progress + '%';
};
// 导入完成:刷新列表
// Import complete: refresh the list
window.HearthEvents.webappImported = function (id, ok) {
if (!ok) return;
refreshList();
};
// 检查更新后刷新列表:清 preload 缓存并重渲染
// Refresh the list after check-update: clear the preload cache and re-render
window.HearthEvents.webappUpdated = function () {
if (window.preload) window.preload.webApps = null;
const page = document.querySelector('[data-page="webapplist"]');
if (page) {
page.dataset.loaded = '';
if (page.classList.contains('active')) window.loadWebAppList();
}
};
// 点击菜单外部关闭长按菜单(touchstart 更及时,移动端可靠)
// Close the long-press menu when tapping outside (touchstart fires promptly on mobile)
document.addEventListener('touchstart', (e) => {
document.querySelectorAll('.wapp-menu').forEach((m) => {
if (!m.contains(e.target)) m.remove();
});
}, { passive: true });
+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
}
},
};
+45 -11
View File
@@ -1,11 +1,21 @@
// 桌面路由:侧边栏 5 页 + 页面切换 + 顶部时间 // 桌面路由:侧边栏 5 页 + 页面切换 + 顶部时间
// Launcher router: 5 sidebar pages + page switching + top clock // Launcher router: 5 sidebar pages + page switching + top clock
// 侧边栏图标:内联 SVGstroke=currentColor,跟随主题色),替代 emoji
// Sidebar icons: inline SVG (stroke=currentColor, follows theme color), replacing emoji
const SVG_ICONS = {
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/></svg>',
immersive: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11l1.5-4.5A2 2 0 018.4 5h7.2a2 2 0 011.9 1.5L19 11"/><path d="M3 11v5a1 1 0 001 1h1a1 1 0 001-1v-1h12v1a1 1 0 001 1h1a1 1 0 001-1v-5"/><circle cx="7.5" cy="16" r="1.6"/><circle cx="16.5" cy="16" r="1.6"/></svg>',
webapp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 010 18a15 15 0 010-18"/></svg>',
app: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33a1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
};
const PAGES = [ const PAGES = [
{ id: 'home', label: '首页', icon: '🏠' }, { id: 'home', label: '首页', icon: SVG_ICONS.home },
{ id: 'immersive', label: '沉浸首页', icon: '🚗' }, { id: 'immersive', label: '沉浸首页', icon: SVG_ICONS.immersive },
{ id: 'webapplist', label: 'H5 应用', icon: '🌐' }, { id: 'webapplist', label: 'H5 应用', icon: SVG_ICONS.webapp },
{ id: 'applist', label: '安卓 APP', icon: '📱' }, { id: 'applist', label: '安卓 APP', icon: SVG_ICONS.app },
{ id: 'settings', label: '设置', icon: '⚙️' }, { id: 'settings', label: '设置', icon: SVG_ICONS.settings },
]; ];
const router = { const router = {
@@ -14,12 +24,36 @@ const router = {
this.current = id; this.current = id;
document.querySelectorAll('[data-page]').forEach(el => document.querySelectorAll('[data-page]').forEach(el =>
el.classList.toggle('active', el.dataset.page === id)); el.classList.toggle('active', el.dataset.page === id));
// 非首页时侧边栏顶部显示小时间 // 切页时把前台 webapp 丢到后台(隐藏内容 WebView,标签状态保留)
// Show the small clock at the sidebar top when not on home // Hide the foreground webapp when switching pages (keep tab state)
bridge.call('hideWebApps').catch(() => {});
// 同时隐藏 webapp 顶栏与标签面板(离开 webapp 场景)
// Also hide the webapp topbar and tab panel (leaving the webapp context)
const topbar = document.getElementById('web-topbar');
if (topbar) topbar.style.display = 'none';
const tabPanel = document.getElementById('tab-panel');
if (tabPanel) tabPanel.style.display = 'none';
// 移除 webapp-activewebapp 已丢后台,恢复 webapp 列表显示
// Remove webapp-active: the webapp is backgrounded, restore the list display
document.body.classList.remove('webapp-active');
// 关闭长按菜单(切页时)
// Close the long-press menu on page switch
document.querySelectorAll('.wapp-menu').forEach((m) => m.remove());
// 非首页时侧边栏顶部显示小时间(容器始终占位,仅切换可见性避免撑动图标)
// Show the small clock at the sidebar top when not on home (the container
// always occupies space; only toggle visibility to avoid shifting icons)
const clock = document.getElementById('rail-clock'); const clock = document.getElementById('rail-clock');
if (clock) clock.style.display = (id === 'home') ? 'none' : 'block'; 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(); // 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();
});
} }
}; };
+37
View File
@@ -0,0 +1,37 @@
// 主题三态管理:跟随系统 / 浅色 / 深色,存 localStorage,通过 data-theme 覆盖 CSS token
// Theme tri-state management: system / light / dark, persisted in localStorage,
// overriding CSS tokens via the data-theme attribute
(function () {
const KEY = 'hearth-theme';
const ORDER = ['system', 'light', 'dark'];
const LABELS = { system: '跟随系统', light: '浅色', dark: '深色' };
window.theme = {
// 当前主题(默认跟随系统)
get() {
return localStorage.getItem(KEY) || 'system';
},
// 应用主题:写入 data-theme 属性(system 时移除)
apply(t) {
localStorage.setItem(KEY, t);
const root = document.documentElement;
if (t === 'light') root.setAttribute('data-theme', 'light');
else if (t === 'dark') root.setAttribute('data-theme', 'dark');
else root.removeAttribute('data-theme');
},
// 三态循环,返回新主题
cycle() {
const cur = this.get();
const next = ORDER[(ORDER.indexOf(cur) + 1) % ORDER.length];
this.apply(next);
return next;
},
// 主题显示名
label(t) {
return LABELS[t] || t;
},
};
// 启动即应用已存主题
window.theme.apply(window.theme.get());
})();
+15 -4
View File
@@ -28,12 +28,21 @@ window.showTopbar = function (tabs) {
fwd.onclick = () => bridge.call('webGoForward'); fwd.onclick = () => bridge.call('webGoForward');
reload.onclick = () => bridge.call('webReload'); reload.onclick = () => bridge.call('webReload');
} }
// 每次刷新都重绑,避免捕获旧 tabs(陈旧闭包 // 标签按钮:调原生弹出标签面板(PopupWindow 在内容 WebView 之上
// Rebind on every refresh to avoid capturing stale tabs (stale closure) // Tab button: show the native tab panel (PopupWindow above the content WebView)
document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs); document.getElementById('tb-tabs').onclick = () => bridge.call('showTabPanel');
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`; document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
const active = tabs.find(t => t.active); const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : ''; document.getElementById('tb-title').textContent = active ? active.name : '';
// 无标签时隐藏顶栏与标签面板(回到桌面)
// Hide the topbar and tab panel when no tabs remain (back to desktop)
bar.style.display = tabs.length ? 'flex' : 'none';
const panel = document.getElementById('tab-panel');
if (panel && !tabs.length) panel.style.display = 'none';
// 有 webapp 时隐藏 webapplist 页面内容(避免半透明顶栏透出列表控件),无 webapp 时恢复
// Hide the webapp list content while a webapp is open (so the translucent topbar
// doesn't show the list controls through it); restore when none are open
document.body.classList.toggle('webapp-active', tabs.length > 0);
}; };
// 标签面板:列出所有标签,点击切换,× 关闭 // 标签面板:列出所有标签,点击切换,× 关闭
@@ -64,7 +73,9 @@ function showTabPanel(tabs) {
row.append(name, close); row.append(name, close);
panel.appendChild(row); panel.appendChild(row);
}); });
panel.style.display = 'block'; // 横向展开(CSS flex-direction: row
// Expand horizontally (CSS flex-direction: row)
panel.style.display = 'flex';
} }
// 收起标签面板 // 收起标签面板
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>示例应用</title>
<style>
/* Miuix 风格示例页面 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "MiSans", "PingFang SC", sans-serif;
background: #f5f5f7;
color: #1a1a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
}
.icon {
width: 72px; height: 72px; border-radius: 20px;
background: linear-gradient(135deg, #ff6900, #ff9d00);
display: flex; align-items: center; justify-content: center;
font-size: 34px; color: #fff;
}
h1 { font-size: 24px; font-weight: 600; }
.card {
background: #fff; border-radius: 16px; padding: 16px 20px;
width: 100%; max-width: 360px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; }
.row .k { color: #8a8a90; }
.note { font-size: 12px; color: #8a8a90; text-align: center; line-height: 1.6; }
</style>
</head>
<body>
<div class="icon">🎨</div>
<h1>示例应用</h1>
<div class="card">
<div class="row"><span class="k">来源</span><span>Hearth 内置</span></div>
<div class="row"><span class="k">当前时间</span><span id="clock">--:--:--</span></div>
</div>
<p class="note">这是打包在 Hearth APK 内的示例 webapp<br>用于演示 webAPP 打开 / 顶栏导航 / 多标签</p>
<script>
function tick() {
var now = new Date();
var p = function (n) { return String(n).padStart(2, '0'); };
document.getElementById('clock').textContent =
p(now.getHours()) + ':' + p(now.getMinutes()) + ':' + p(now.getSeconds());
}
tick();
setInterval(tick, 1000);
</script>
</body>
</html>
+520 -12
View File
@@ -1,46 +1,253 @@
package top.yeij.hearth package top.yeij.hearth
import android.app.Activity import android.app.Activity
import android.app.WallpaperManager
import android.content.ContentValues
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.provider.Settings
import android.text.TextUtils
import android.util.Base64
import android.util.Log import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.app.NotificationManagerCompat
import com.google.gson.Gson
import top.yeij.hearth.app.AndroidAppSource import top.yeij.hearth.app.AndroidAppSource
import top.yeij.hearth.app.AppRepository import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
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.ServerUrlProvider
import top.yeij.hearth.webview.WallpaperProvider
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
// HOME 桌面 activity后续 Task 挂载 WebView 与 JsBridge // HOME 桌面 activity接线各能力层:Repository、媒体监听、多 WebView webAPP 管理
// HOME launcher activity; later tasks mount WebView and JsBridge // HOME launcher activity, wiring all capability layers: repositories, media listener,
// and multi-WebView webapp management
class MainActivity : Activity() { class MainActivity : Activity() {
companion object { companion object {
private const val TAG = "HearthMainActivity" private const val TAG = "HearthMainActivity"
// 占位清单/目录地址,真机部署时可替换为实际服务器
// Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
// 内容 WebView 顶部预留高度(dp):默认只留顶栏(约 54dp);标签面板弹出时
// 通过 setContentTopMargin 临时增大,收起后恢复,避免一直压缩 webapp 高度
// Content WebView top reserve (dp): default only the topbar (~54dp); the tab
// panel temporarily grows it via setContentTopMargin and restores on collapse,
// avoiding permanently shrinking the webapp height
private const val TOPBAR_HEIGHT_DP = 56
// 全局侧边栏收起宽度(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
// SAF 导入离线包的 requestCode
private const val REQUEST_IMPORT_ZIP = 1001
} }
private val webAppContainer = WebAppContainer()
private val webAppStorage = WebAppStorage(this)
// 待导入离线包的 webapp idSAF 回调用)
// Pending webapp id for offline-package import (used in the SAF callback)
private var pendingImportId: String? = null
private val gson = Gson()
private val webAppHost = WebAppHostImpl()
// 通知使用权访问实现:检查授权状态 + 跳转系统授权页(供设置页授权项使用)
// Notification-access implementation: check grant state + jump to system settings
// (used by the settings permission item)
private val notificationAccess = object : NotificationAccessProvider {
override fun isGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this@MainActivity)
.contains(packageName)
override fun requestAccess() {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
}
// 系统亮度访问实现:读/写 Settings.System.SCREEN_BRIGHTNESS,需 WRITE_SETTINGS 授权
// System brightness implementation: read/write Settings.System.SCREEN_BRIGHTNESS,
// requiring the WRITE_SETTINGS grant
private val brightnessProvider = object : BrightnessProvider {
override fun getSystemBrightness(): Int =
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
override fun setSystemBrightness(value: Int) {
Settings.System.putInt(
contentResolver,
Settings.System.SCREEN_BRIGHTNESS,
value.coerceIn(0, 255)
)
}
override fun canWriteSettings(): Boolean = Settings.System.canWrite(this@MainActivity)
override fun requestWriteSettings() {
startActivity(
Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName"))
)
}
}
// 服务器地址存储实现:SharedPreferences 持久化,设置时同步更新 WebAppRepository
// Server URL storage implementation: persisted in SharedPreferences, syncing the
// WebAppRepository manifest URL on change
private val serverUrlProvider = object : ServerUrlProvider {
private val prefs by lazy { getSharedPreferences("hearth", MODE_PRIVATE) }
override fun getServerUrl(): String =
prefs.getString("server_url", MANIFEST_URL) ?: MANIFEST_URL
override fun setServerUrl(url: String) {
prefs.edit().putString("server_url", url).apply()
if (::webAppRepository.isInitialized) webAppRepository.setServerUrl(url)
}
}
// 壁纸提供实现: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
private lateinit var desktopWebView: WebView
private lateinit var bridge: JsBridge
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: enter immersive mode") Log.d(TAG, "onCreate: setup webview then enter immersive mode")
enterImmersive() // 先 setContentView 创建 DecorView,再进沉浸式;否则 window.insetsController 会
// 因 DecorView 尚未创建(null)而 NPE 崩溃
// Set content view first to create the DecorView, then enter immersive mode;
// otherwise window.insetsController throws NPE because DecorView is not created yet
setupWebView() setupWebView()
enterImmersive()
checkNotificationAccess()
} }
// 创建 JsBridge 并挂载 WebView 桌面层 override fun onDestroy() {
// Create JsBridge and mount the WebView launcher layer mediaSource.stop()
webAppHost.destroyAll()
super.onDestroy()
}
// 创建 JsBridge 并挂载桌面 WebView(底层),接线所有 Repository 与媒体监听
// Create JsBridge and mount the desktop WebView (bottom layer), wiring all
// repositories and the media session listener
private fun setupWebView() { private fun setupWebView() {
val dm = resources.displayMetrics val dm = resources.displayMetrics
val darkMode = val darkMode =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
val bridge = JsBridge( val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient()
webAppRepository = WebAppRepository(http, cache, serverUrlProvider.getServerUrl())
bridge = JsBridge(
deviceWidthPx = dm.widthPixels, deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels, deviceHeightPx = dm.heightPixels,
density = dm.density, density = dm.density,
darkMode = darkMode, darkMode = darkMode,
appRepository = AppRepository(AndroidAppSource(this)), appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = webAppRepository,
cardRepository = CardRepository(http, cache, CATALOG_URL),
webAppContainer = webAppContainer,
webAppHost = webAppHost,
postToMainThread = { desktopWebView.post(it) },
notificationAccess = notificationAccess,
brightness = brightnessProvider,
serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider,
onShowTabPanel = { showNativeTabPanel() },
storage = webAppStorage,
onImportOfflinePackage = { id -> startImportOfflinePackage(id) },
onExportLog = { exportLog() },
) )
WebViewManager(this).attach(bridge) root = FrameLayout(this)
setContentView(root)
desktopWebView = WebViewManager(this).attach(bridge, root)
desktopWebView.webViewClient = object : WebViewClient() {
// 主帧加载完成时打日志,便于定位黑屏(判断 index.html 是否成功加载)
// 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()
}
// 主帧加载失败时展示错误页,避免白屏
// Show an error page on main-frame load failure to avoid a blank screen
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "onReceivedError: code=$code desc=$description url=$failingUrl")
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
mediaSource = MediaSessionSource(this)
bridge.setMediaListener(mediaSource, desktopWebView)
Log.d( Log.d(
TAG, TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" + "setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
@@ -48,6 +255,299 @@ class MainActivity : Activity() {
) )
} }
// webAPP 内容 WebView 宿主实现:管理 id → WebView 映射、可见性、导航、顶栏同步
// Webapp content WebView host implementation: manage id -> WebView mapping,
// visibility, navigation, and topbar sync
private inner class WebAppHostImpl : WebAppHost {
private val contentWebViews = mutableMapOf<String, WebView>()
private var currentVisibleId: String? = null
private val topBarHeightPx by lazy {
(TOPBAR_HEIGHT_DP * resources.displayMetrics.density).toInt()
}
private val sidebarWidthPx by lazy {
(SIDEBAR_WIDTH_DP * resources.displayMetrics.density).toInt()
}
// 加载失败的 webapp idBack 键时直接关闭而非 goBack 循环回退
// Failed webapp ids: on Back, close directly instead of looping goBack
private val failedWebViews = mutableSetOf<String>()
override fun openWebView(id: String, url: String, ua: String?, scale: Int?) {
if (contentWebViews.containsKey(id)) {
switchWebView(id)
return
}
val webView = WebViewManager(this@MainActivity).createContentWebView(ua, scale)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
Log.d(TAG, "content onPageFinished: url=$url scale=$scale")
// 缩放:CSS zoom(会 reflow,内容填满不留白),对任何页面可靠生效
// Zoom: CSS zoom (reflows, no blank space), reliable on any page
if (scale != null && scale in 1..100) {
val zoom = scale / 100f
view?.evaluateJavascript(
"(function(){var z='$zoom';var s=document.createElement('style');s.innerHTML='html{zoom:'+z+'}';document.head.appendChild(s);})();",
null
)
}
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "content onReceivedError: code=$code desc=$description url=$failingUrl")
failedWebViews.add(id)
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
// 推送 webapp 页面加载进度给 H50-100
// Push the webapp page load progress to H5 (0-100)
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $newProgress);",
null
)
}
}
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
params.topMargin = topBarHeightPx
params.leftMargin = sidebarWidthPx
contentWebViews[id] = webView
root.addView(webView, params)
webView.loadUrl(url)
switchWebView(id)
Log.d(TAG, "openWebView: id=$id url=$url topMargin=$topBarHeightPx")
}
override fun closeWebView(id: String) {
val webView = contentWebViews.remove(id) ?: return
failedWebViews.remove(id)
root.removeView(webView)
webView.stopLoading()
webView.destroy()
if (currentVisibleId == id) currentVisibleId = null
Log.d(TAG, "closeWebView: id=$id remaining=${contentWebViews.size}")
}
override fun switchWebView(id: String) {
currentVisibleId = id
contentWebViews.forEach { (tabId, webView) ->
webView.visibility = if (tabId == id) View.VISIBLE else View.GONE
}
Log.d(TAG, "switchWebView: id=$id")
}
override fun hideAll() {
contentWebViews.values.forEach { it.visibility = View.GONE }
currentVisibleId = null
Log.d(TAG, "hideAll: hidden ${contentWebViews.size} webviews")
}
override fun goBack(): Boolean {
val id = currentVisibleId ?: return false
// 加载失败的 webappBack 直接关闭(返回 false 让 onBackPressed 关闭标签),
// 避免 goBack 回退到失败的 file:// 页面反复加载
if (failedWebViews.contains(id)) return false
val webView = contentWebViews[id] ?: return false
return if (webView.canGoBack()) {
webView.goBack()
true
} else {
false
}
}
override fun goForward(): Boolean {
val webView = currentVisibleId?.let { contentWebViews[it] }
return if (webView != null && webView.canGoForward()) {
webView.goForward()
true
} else {
false
}
}
override fun reload() {
currentVisibleId?.let { contentWebViews[it] }?.reload()
}
override fun syncTabs(tabs: List<Tab>) {
// JsBridge 已把该调用 marshal 到主线程,这里可直接 evaluateJavascript
// JsBridge already marshaled this call to the main thread, so evaluateJavascript is safe
val json = gson.toJson(
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
)
desktopWebView.evaluateJavascript(
"window.showTopbar && window.showTopbar($json);",
null
)
Log.d(TAG, "syncTabs: ${tabs.size} tabs")
}
// 销毁全部内容 WebViewActivity 退出时释放资源)
// Destroy all content WebViews (release resources on activity teardown)
fun destroyAll() {
contentWebViews.values.forEach { it.stopLoading(); it.destroy() }
contentWebViews.clear()
currentVisibleId = null
}
}
// 弹出原生标签面板(PopupWindow,在内容 WebView 之上,不占用预留高度)
// Show the native tab panel (PopupWindow above the content WebView, no reserved height)
private fun showNativeTabPanel() {
val tabs = webAppContainer.tabs()
if (tabs.isEmpty()) return
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
background = GradientDrawable().apply {
setColor(0xE6202024.toInt())
cornerRadius = dp(16).toFloat()
}
}
val popup = PopupWindow(container, dp(220), ViewGroup.LayoutParams.WRAP_CONTENT, true)
popup.isOutsideTouchable = true
tabs.forEach { tab ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(8), dp(12), dp(8))
if (tab.active) background = GradientDrawable().apply {
setColor(Color.parseColor("#ff6900"))
cornerRadius = dp(10).toFloat()
}
}
val name = TextView(this).apply {
text = tab.name
textSize = 14f
setTextColor(Color.WHITE)
isSingleLine = true
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val close = TextView(this).apply {
text = ""
textSize = 14f
setTextColor(Color.WHITE)
setPadding(dp(10), 0, 0, 0)
setOnClickListener {
bridge.closeWebApp(tab.id)
popup.dismiss()
}
}
row.addView(name)
row.addView(close)
row.setOnClickListener {
bridge.switchTab(tab.id)
popup.dismiss()
}
container.addView(row)
}
popup.showAtLocation(root, Gravity.TOP or Gravity.END, dp(16), dp(64))
}
// dp 转 px
// dp to px
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// 导出日志到 Download 目录(读 logcat 本进程 + 过滤 Hearth 相关行)
// Export the log to the Download directory (read logcat, filter Hearth lines)
private fun exportLog(): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
val log = process.inputStream.bufferedReader().readText()
val filtered = log.lines()
.filter { it.contains("Hearth") }
.joinToString("\n")
val name = "hearth-log-" + System.currentTimeMillis() + ".txt"
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, "text/plain")
put(MediaStore.Downloads.RELATIVE_PATH, "Download/")
}
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return "导出失败:无法创建文件"
contentResolver.openOutputStream(uri)?.use { out ->
out.write(filtered.toByteArray())
} ?: return "导出失败:无法写入"
Log.d(TAG, "exportLog: $name (${filtered.length} chars)")
"已导出:Download/$name"
} catch (e: Exception) {
Log.e(TAG, "exportLog failed: ${e.message}")
"导出失败:${e.message}"
}
}
// 触发 SAF 选择 zip 导入离线包
// Trigger SAF to pick a zip for offline-package import
private fun startImportOfflinePackage(id: String) {
pendingImportId = id
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
}
startActivityForResult(intent, REQUEST_IMPORT_ZIP)
}
// SAF 结果:导入离线包,成功后通知 H5(下次点开用本地)
// SAF result: import the offline package, notify H5 on success (next open uses local)
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_IMPORT_ZIP && resultCode == Activity.RESULT_OK) {
val id = pendingImportId ?: return
val uri = data?.data ?: return
val ok = webAppStorage.importPackage(id, uri)
Log.d(TAG, "importPackage: id=$id ok=$ok")
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappImported('$id', $ok);",
null
)
}
pendingImportId = null
}
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task)
private fun checkNotificationAccess() {
val granted = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
Log.d(TAG, "checkNotificationAccess: notification listener enabled=$granted")
if (!granted) {
Log.d(
TAG,
"checkNotificationAccess: grant via Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
)
}
}
// 构建加载失败错误页(转义系统错误描述,避免注入)
// Build the load-failure error page (escape the system error description)
private fun errorPage(desc: String): String {
val safe = TextUtils.htmlEncode(desc)
return """
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100vh;margin:0;background:transparent;color:#999}
h2{font-weight:400;color:#ccc}
p{font-size:14px;max-width:80vw;text-align:center;word-break:break-all}
</style></head><body><h2>加载失败</h2><p>$safe</p></body></html>
""".trimIndent()
}
// 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出 // 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出
// Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently // Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently
private fun enterImmersive() { private fun enterImmersive() {
@@ -58,11 +558,19 @@ class MainActivity : Activity() {
} }
} }
// Back 键桌面态屏蔽(webAPP 打开时由 WebAppContainer 接管) // Back 键webAPP 打开时先回退,无历史则关闭激活标签;桌面态吞掉
// Swallow Back key on desktop state (WebAppContainer takes over when a webAPP is open) // Back key: when a webAPP is open, go back first, then close the active tab if
// no history; consume the event on desktop state
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onBackPressed() { override fun onBackPressed() {
// 桌面态:吞掉,不响应 val active = webAppContainer.tabs().firstOrNull { it.active }
// Desktop state: consume the event, do not respond if (active != null) {
if (!webAppHost.goBack()) {
bridge.closeWebApp(active.id)
}
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return
}
Log.d(TAG, "onBackPressed: desktop state, swallowed")
} }
} }
@@ -22,24 +22,38 @@ class CardRepository(
fun fetchCatalog(): List<Card> { fun fetchCatalog(): List<Card> {
val body = http.get(catalogUrl) val body = http.get(catalogUrl)
if (body != null) { if (body != null) {
cache.save(KEY, body) // 先解析、成功才缓存,避免畸形 body 被永久缓存
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network") // Parse first, cache only on success (never cache malformed bodies)
return parse(body) val cards = parse(body)
if (cards != null) {
cache.save(KEY, body)
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
return cards
}
Log.w(TAG, "fetchCatalog: invalid catalog body, not cached")
return builtin()
} }
val cached = cache.load(KEY) val cached = cache.load(KEY)
if (cached != null) { if (cached != null) {
Log.d(TAG, "fetchCatalog: network failed, using cache") Log.d(TAG, "fetchCatalog: network failed, using cache")
return parse(cached) return parse(cached) ?: builtin()
} }
Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card") Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card")
return builtin() return builtin()
} }
// 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项 // 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项
// Parse catalog JSON; skip malformed or missing-field entries // 畸形 JSON 或缺少 cards 字段返回 null(调用方回退内置 time 卡)
private fun parse(body: String): List<Card> { // Parse catalog JSON; skip malformed or missing-field entries. Return null on
val root = gson.fromJson(body, Map::class.java) // malformed JSON or a missing cards field (caller falls back to builtin time card)
val list = root["cards"] as? List<*> ?: return builtin() private fun parse(body: String): List<Card>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid catalog JSON: ${e.message}")
return null
}
val list = root["cards"] as? List<*> ?: return null
val parsed = list.mapNotNull { m -> val parsed = list.mapNotNull { m ->
val map = m as? Map<*, *> ?: return@mapNotNull null val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null val id = map["id"] as? String ?: return@mapNotNull null
@@ -0,0 +1,34 @@
package top.yeij.hearth.media
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
// 通知监听服务:作为「通知使用权」授权凭据(让 getActiveSessions 可用),
// 同时监听媒体通知的实时标题变化(如网易云歌词实时显示在通知标题)。
// Notification listener service: serves as the "notification access" credential
// (making getActiveSessions usable), and also watches media notification title
// changes (e.g. NetEase Cloud Music shows live lyrics in the notification title).
class HearthNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "HearthNotifListener"
// 媒体通知实时标题回调(packageName -> title),供 MediaSessionSource 订阅
// Media notification live-title callback (packageName -> title), subscribed by MediaSessionSource
@Volatile
var onMediaTitle: ((packageName: String, title: String) -> Unit)? = null
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn ?: return
val n = sbn.notification ?: return
// 仅处理媒体通知(CATEGORY_TRANSPORT
// Only handle media notifications (CATEGORY_TRANSPORT)
if (n.category != Notification.CATEGORY_TRANSPORT) return
val title = n.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: return
Log.d(TAG, "onNotificationPosted: ${sbn.packageName} title=$title")
onMediaTitle?.invoke(sbn.packageName, title)
}
}
@@ -1,11 +1,12 @@
package top.yeij.hearth.media package top.yeij.hearth.media
// 媒体会话快照:标题/艺术家/专辑 + 播放状态/进度 + 来源包名 // 媒体会话快照:标题/艺术家/专辑 + 封面 + 播放状态/进度 + 来源包名
// Media session snapshot: title/artist/album + playback state/progress + source package // Media session snapshot: title/artist/album + cover + playback state/progress + source package
data class MediaInfo( data class MediaInfo(
val title: String, val title: String,
val artist: String, val artist: String,
val album: String, val album: String,
val cover: String,
val playing: Boolean, val playing: Boolean,
val position: Long, val position: Long,
val duration: Long, val duration: Long,
@@ -1,41 +1,265 @@
package top.yeij.hearth.media package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadata 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.Log import android.util.Log
import java.io.ByteArrayOutputStream
// 监听系统活跃媒体会话,取第一个 controller 的 metadata + playbackState 构造 MediaInfo 回调 // 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState // 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
// 提供播放控制(上一首/暂停播放/下一首)、封面提取与跳转播放界面。
// Watch active media sessions (supports multiple: music/audiobook/video at once),
// push in real time on metadata/playback-state change (track switch, play/pause);
// provide transport controls (previous/play-pause/next), cover extraction and
// jumping to the playback UI.
class MediaSessionSource(context: Context) { class MediaSessionSource(context: Context) {
private val appContext = context.applicationContext
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
private var listener: ((MediaInfo?) -> Unit)? = null
fun start(cb: (MediaInfo?) -> Unit) { // 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
listener = cb // 取代依赖 MEDIA_CONTENT_CONTROL 系统权限的 null 方案。
msm.addOnActiveSessionsChangedListener({ controllers -> // The notification listener component is passed as the authorization credential,
val c = controllers?.firstOrNull() // replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
if (c == null) { private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
Log.d("HearthMedia", "no active media session")
cb(null) // 通知实时标题缓存(packageName -> title),来自通知栏的实时歌词,
return@addOnActiveSessionsChangedListener // 优先于 MediaSession 的 METADATA_KEY_TITLE(歌词只在通知里更新)
// Live notification title cache (packageName -> title) from the notification
// shade; it takes priority over MediaSession METADATA_KEY_TITLE because lyrics
// only update in the notification
private val notificationTitles = mutableMapOf<String, String>()
// 封面缓存(packageName -> base64):部分 App 切歌瞬间 ART 为 null,用缓存兜底,
// 避免媒体卡横划切换后封面消失
// Cover cache (packageName -> base64): some apps briefly return null ART on track
// switch; fall back to the cached cover so the card cover doesn't vanish on swipe
private val coverCache = mutableMapOf<String, String>()
init {
HearthNotificationListenerService.onMediaTitle = { pkg, title ->
notificationTitles[pkg] = title
pushCurrent()
}
}
private var callback: ((List<MediaInfo>) -> Unit)? = null
private var registered = false
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
private val mediaCallback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) {
Log.d(TAG, "onMetadataChanged: ${metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)}")
pushCurrent()
}
override fun onPlaybackStateChanged(state: PlaybackState?) {
Log.d(TAG, "onPlaybackStateChanged: state=${state?.state}")
pushCurrent()
}
}
// 活跃会话列表变化时回调
// Fires when the active-session list changes
private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { list ->
if (callback == null) return@OnActiveSessionsChangedListener
updateControllers(list?.toList() ?: emptyList())
pushCurrent()
}
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
// 注册后主动拉取一次当前会话:注册前已开始的播放不会触发变化回调。
// Register the listener: idempotent — remove the old one first to avoid accumulation.
// After registering, actively pull the current sessions once.
fun start(cb: (List<MediaInfo>) -> Unit) {
stop()
callback = cb
try {
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true
Log.d(TAG, "start: media session listener registered")
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
// 启动轮询兜底
pollHandler.removeCallbacks(pollRunnable)
pollHandler.postDelayed(pollRunnable, 2000)
} catch (e: SecurityException) {
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
// MediaSessionService throws SecurityException without notification access;
// degrade to media listening disabled instead of crashing.
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
registered = false
}
}
// 注销监听:释放回调引用,供 Activity onDestroy 调用
// Unregister the listener: release the callback, call from Activity onDestroy
fun stop() {
if (registered) {
msm.removeOnActiveSessionsChangedListener(activeSessionsListener)
registered = false
Log.d(TAG, "stop: media session listener removed")
}
unregisterCallbacks()
pollHandler.removeCallbacks(pollRunnable)
callback = null
}
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
// Actively pull the current sessions and notify (without re-registering)
fun refresh() {
if (callback == null) return
try {
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
} catch (e: SecurityException) {
Log.w(TAG, "refresh: no notification access: ${e.message}")
}
}
// 播放控制:作用于指定索引的 controller(对应 H5 当前滑到的会话)
// Transport controls: act on the controller at the given index (matching the
// session the H5 card is currently showing)
fun previous(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToPrevious()
Log.d(TAG, "previous: index=$index")
}
fun next(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToNext()
Log.d(TAG, "next: index=$index")
}
fun playPause(index: Int) {
val c = controllers.getOrNull(index) ?: return
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
if (playing) c.transportControls.pause() else c.transportControls.play()
Log.d(TAG, "playPause: index=$index playing=$playing")
}
// 拖动进度条 seek 到指定位置(毫秒)
// Seek to the given position (ms) when dragging the progress bar
fun seekTo(index: Int, position: Long) {
controllers.getOrNull(index)?.transportControls?.seekTo(position)
Log.d(TAG, "seekTo: index=$index position=$position")
}
// 跳转到指定会话的播放界面:优先 sessionActivityPendingIntent),
// 失败则回退到启动该 App
// Jump to the session's playback UI: prefer sessionActivity (PendingIntent),
// fall back to launching the app
fun openMediaApp(index: Int) {
val c = controllers.getOrNull(index) ?: return
val sessionActivity = c.sessionActivity
if (sessionActivity != null) {
try {
sessionActivity.send()
Log.d(TAG, "openMediaApp: sessionActivity sent for ${c.packageName}")
return
} catch (e: Exception) {
Log.w(TAG, "openMediaApp: sessionActivity failed: ${e.message}")
} }
val meta = c.metadata }
val state = c.playbackState val launch = appContext.packageManager.getLaunchIntentForPackage(c.packageName)
cb( if (launch != null) {
MediaInfo( launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "", appContext.startActivity(launch)
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "", Log.d(TAG, "openMediaApp: launched ${c.packageName}")
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "", }
state?.state == PlaybackState.STATE_PLAYING, }
state?.position ?: 0L,
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L, // 更新 controller 列表:注销旧回调、注册新回调
c.packageName, // Update the controller list: unregister old callbacks, register new ones
) private fun updateControllers(list: List<MediaController>) {
) unregisterCallbacks()
Log.d("HearthMedia", "media: ${c.packageName}") controllers.clear()
}, null) controllers.addAll(list)
controllers.forEach { it.registerCallback(mediaCallback) }
}
private fun unregisterCallbacks() {
controllers.forEach {
try {
it.unregisterCallback(mediaCallback)
} catch (e: Exception) {
// ignore
}
}
}
// 推送当前全部会话的 MediaInfo 列表给 H5
// Push the current MediaInfo list for all sessions to H5
private fun pushCurrent() {
callback?.let { cb -> cb(controllers.map { toMediaInfo(it) }) }
}
// 从 controller 提取 metadata + playbackState + 封面构造 MediaInfo
// Build MediaInfo from the controller's metadata + playbackState + cover
private fun toMediaInfo(c: MediaController): MediaInfo {
val meta = c.metadata
val state = c.playbackState
// 标题优先用通知实时标题(歌词),fallback 到 MediaSession 元数据
// Title prefers the live notification title (lyrics), falling back to MediaSession metadata
val title = notificationTitles[c.packageName]
?: (meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "")
val duration = meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L
val position = state?.position ?: 0L
val playing = state?.state == PlaybackState.STATE_PLAYING
// 封面:优先当前 ART,为空时用缓存兜底(切歌瞬间 ART 可能为 null)
// Cover: prefer current ART, fall back to cache when empty (ART may be null
// briefly on track switch)
val cover = meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) }
?: coverCache[c.packageName]
?: ""
if (cover.isNotEmpty()) coverCache[c.packageName] = cover
Log.d(TAG, "media: ${c.packageName} duration=$duration position=$position playing=$playing cover=${cover.isNotEmpty()}")
return MediaInfo(
title,
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
cover,
playing,
position,
duration,
c.packageName,
)
}
// 封面缩放到 128pxJPEG 压缩转 base64data URI
// Scale the cover to 128px, JPEG-compress to base64 (data URI)
private fun bitmapToBase64(bmp: Bitmap): String {
val size = 128
val scaled = Bitmap.createScaledBitmap(bmp, size, size, true)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
scaled.recycle()
return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
companion object {
private const val TAG = "HearthMedia"
} }
} }
@@ -0,0 +1,28 @@
package top.yeij.hearth.webapp
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
// 基于 OkHttp 的 HttpClient 实现:GET 返回响应体字符串,失败/非 2xx 返回 null
// OkHttp-backed HttpClient: GET returns the response body string, null on failure or non-2xx
class OkHttpHttpClient(
private val client: OkHttpClient = OkHttpClient(),
) : HttpClient {
override fun get(url: String): String? {
return try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) resp.body?.string() else null
}
} catch (e: IOException) {
Log.d(TAG, "get failed: $url -> ${e.message}")
null
}
}
companion object {
private const val TAG = "HearthHttp"
}
}
@@ -1,30 +1,42 @@
package top.yeij.hearth.webapp package top.yeij.hearth.webapp
// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签 // 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签name 为展示名
// Single WebView tab: id is the unique key, active marks the currently-focused tab // Single WebView tab: id is the unique key, active marks the currently-focused tab,
data class Tab(val id: String, val url: String, val active: Boolean = false) // name is the display title (from the web app manifest)
data class Tab(val id: String, val url: String, val active: Boolean = false, val name: String = "")
// 多标签状态管理(纯 Kotlin 数据层,不依赖 WebView,可 JVM 单测) // 多标签状态管理(纯 Kotlin 数据层,不依赖 WebView,可 JVM 单测)
// 真实 WebView 导航(goBack/goForward/reload)留待 Task 13 集成时接入 // 真实 WebView 导航(goBack/goForward/reload)留待 Task 13 集成时接入
// Multi-tab state management (pure Kotlin data layer, WebView-free, JVM-testable) // Multi-tab state management (pure Kotlin data layer, WebView-free, JVM-testable)
// Real WebView navigation (goBack/goForward/reload) is wired up in Task 13 // Real WebView navigation (goBack/goForward/reload) is wired up in Task 13
//
// 状态方法全部 @Synchronizedopen/close/switchTo 写操作在主线程,listTabs/tabs 读操作
// 在 JavaBridge 线程,跨线程读写同一 mutableListOf,需用同一把锁串行,避免
// ConcurrentModificationException(尤其 tabs() 的 toList() 迭代与写操作互斥)
// All state methods are @Synchronized: open/close/switchTo write on the main thread
// while listTabs/tabs read on the JavaBridge thread; reading and writing the same
// mutableListOf across threads must serialize on a single lock to avoid
// ConcurrentModificationException (especially toList() iteration vs writes)
class WebAppContainer { class WebAppContainer {
private val tabs = mutableListOf<Tab>() private val tabs = mutableListOf<Tab>()
// 打开标签:已存在则激活它(不重复添加),否则新增并激活 // 打开标签:已存在则激活它(不重复添加),否则新增并激活name 记录展示名
// Open a tab: if it already exists just activate it, otherwise add and activate // Open a tab: if it already exists just activate it, otherwise add and activate;
fun open(id: String, url: String) { // name records the display title from the manifest
@Synchronized
fun open(id: String, url: String, name: String = "") {
val existing = tabs.find { it.id == id } val existing = tabs.find { it.id == id }
if (existing != null) { if (existing != null) {
setActive(id) setActive(id)
return return
} }
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = false) for (i in tabs.indices) tabs[i] = tabs[i].copy(active = false)
tabs.add(Tab(id, url, active = true)) tabs.add(Tab(id, url, active = true, name = name))
} }
// 关闭标签:若关闭后没有激活标签则激活第一个 // 关闭标签:若关闭后没有激活标签则激活第一个
// Close a tab: if none remain active, activate the first one // Close a tab: if none remain active, activate the first one
@Synchronized
fun close(id: String) { fun close(id: String) {
tabs.removeAll { it.id == id } tabs.removeAll { it.id == id }
if (tabs.isNotEmpty() && tabs.none { it.active }) { if (tabs.isNotEmpty() && tabs.none { it.active }) {
@@ -32,16 +44,25 @@ class WebAppContainer {
} }
} }
// 切换激活标签 // 切换激活标签:id 不存在时返回 false 且不改动状态(保持原激活标签)
// Switch the active tab // Switch the active tab: return false and keep current state when id is unknown
fun switchTo(id: String) { @Synchronized
fun switchTo(id: String): Boolean {
if (tabs.none { it.id == id }) return false
setActive(id) setActive(id)
return true
} }
// 返回标签列表副本(外部无法直接改动内部状态) // 返回标签列表副本(外部无法直接改动内部状态)
// Return a defensive copy of the tab list // Return a defensive copy of the tab list
@Synchronized
fun tabs(): List<Tab> = tabs.toList() fun tabs(): List<Tab> = tabs.toList()
// 返回当前激活标签 id,无标签或未激活返回 null
// Return the active tab id, or null when empty/none active
@Synchronized
fun activeTabId(): String? = tabs.firstOrNull { it.active }?.id
private fun setActive(id: String) { private fun setActive(id: String) {
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id) for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id)
} }
@@ -1,5 +1,7 @@
package top.yeij.hearth.webapp package top.yeij.hearth.webapp
import android.util.Base64
import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager import top.yeij.hearth.cache.CacheManager
@@ -13,6 +15,9 @@ data class WebApp(
val icon: String, val icon: String,
val url: String, val url: String,
val offline: String? = null, val offline: String? = null,
val offlineVersion: String? = null,
val ua: String? = null,
val scale: Int? = null,
) )
// 网络抽象接口:Task 10 的 CardRepository 也会复用 // 网络抽象接口:Task 10 的 CardRepository 也会复用
@@ -26,37 +31,116 @@ interface HttpClient {
class WebAppRepository( class WebAppRepository(
private val http: HttpClient, private val http: HttpClient,
private val cache: CacheManager, private val cache: CacheManager,
private val manifestUrl: String, private var serverUrl: String,
) { ) {
private val gson = Gson() private val gson = Gson()
fun fetchManifest(): List<WebApp> { // 更新服务器根地址(设置页修改服务器地址后调用)
val body = http.get(manifestUrl) // Update the server root URL (called after the settings page changes it)
if (body != null) { fun setServerUrl(url: String) {
cache.save(KEY, body) serverUrl = url
return parse(body) Log.d(TAG, "setServerUrl: $url")
}
val cached = cache.load(KEY) ?: return emptyList()
return parse(cached)
} }
private fun parse(body: String): List<WebApp> { fun fetchManifest(): List<WebApp> {
val root = gson.fromJson(body, Map::class.java) // 服务器地址是根目录,清单固定拉 <根>/manifest.json
val apps = root["apps"] as? List<*> ?: return emptyList() // The server URL is the root; the manifest is always <root>/manifest.json
val manifestUrl = serverUrl.trimEnd('/') + "/manifest.json"
val body = http.get(manifestUrl)
Log.d(TAG, "fetchManifest: url=$manifestUrl bodyNull=${body == null}")
if (body != null) {
// 先解析、成功才缓存,避免畸形 body 或空结果被永久缓存
// Parse first, cache only on success (never cache malformed/empty bodies)
val apps = parse(body)
if (apps != null && apps.isNotEmpty()) {
cache.save(KEY, body)
return apps
}
Log.w(TAG, "fetchManifest: invalid/empty body, not cached")
return builtinWebApps()
}
val cached = cache.load(KEY)
if (cached != null) {
val apps = parse(cached)
if (apps != null && apps.isNotEmpty()) return apps
}
// 无远程清单:返回内置示例 webapp,保证列表不为空、可演示
// No remote manifest: return the builtin demo webapp so the list is non-empty
return builtinWebApps()
}
// 内置示例 webapp(打包在 assets 内,无需服务器)
// Builtin demo webapp (bundled in assets, no server required)
private fun builtinWebApps(): List<WebApp> {
val svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
"<rect width='24' height='24' rx='6' fill='#ff6900'/>" +
"<text x='12' y='17' font-size='13' text-anchor='middle' fill='white'>H</text></svg>"
val icon = "data:image/svg+xml;base64," +
Base64.encodeToString(svg.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
return listOf(
WebApp(
id = "demo",
name = "示例应用",
icon = icon,
url = "file:///android_asset/webapps/demo/index.html",
)
)
}
// 解析清单 JSON;畸形 JSON 或缺少 apps 字段返回 null(调用方决定回退)
// Parse the manifest JSON; return null on malformed JSON / missing apps field
private fun parse(body: String): List<WebApp>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid manifest JSON: ${e.message}")
return null
}
val apps = root["apps"] as? List<*> ?: return null
return apps.mapNotNull { m -> return apps.mapNotNull { m ->
val map = m as? Map<*, *> ?: return@mapNotNull null val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null val id = map["id"] as? String ?: return@mapNotNull null
val name = map["name"] as? String ?: return@mapNotNull null val name = map["name"] as? String ?: return@mapNotNull null
val icon = map["icon"] as? String ?: return@mapNotNull null val icon = map["icon"] as? String ?: return@mapNotNull null
val url = map["url"] as? String ?: return@mapNotNull null val url = map["url"] as? String ?: return@mapNotNull null
// offline 是对象时取其 package 字段,否则 null(纯在线) // offline 是对象时取其 package/version 字段,否则 null(纯在线)
// offline is an object -> take its package field, otherwise null (online-only) // offline is an object -> take its package/version fields, otherwise null (online-only)
val offline = (map["offline"] as? Map<*, *>)?.get("package") as? String val offlineMap = map["offline"] as? Map<*, *>
WebApp(id, name, icon, url, offline) val offline = offlineMap?.get("package") as? String
val offlineVersion = offlineMap?.get("version") as? String
// ua 可选:desktop/pc 用 PC UA,其他视为自定义 UA 字符串,不指定默认平板 UA
// ua optional: desktop/pc uses a desktop UA, others are custom UA strings,
// absent means the default tablet UA
val ua = map["ua"] as? String
// scale 可选:初始缩放百分比(1-100),用于 PC 版页面缩放适配横屏
// scale optional: initial zoom percentage (1-100), for scaling desktop pages
val scale = (map["scale"] as? Number)?.toInt()
WebApp(
id, name,
resolvePath(icon),
resolvePath(url),
offline?.let { resolvePath(it) },
offlineVersion,
ua,
scale,
)
} }
} }
companion object { companion object {
private const val TAG = "HearthWebApp"
private const val KEY = "webapp-manifest" private const val KEY = "webapp-manifest"
} }
// 相对路径拼服务器根;绝对 URL / data URI / file URI 直接返回
// Resolve a relative path against the server root; return absolute URL / data
// URI / file URI as-is
private fun resolvePath(path: String): String {
if (path.startsWith("http://") || path.startsWith("https://") ||
path.startsWith("data:") || path.startsWith("file:")
) {
return path
}
return serverUrl.trimEnd('/') + "/" + path.trimStart('/')
}
} }
@@ -0,0 +1,160 @@
package top.yeij.hearth.webapp
import android.content.Context
import android.net.Uri
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
// 离线包存储:下载/解压/手动导入 webAPP 离线包到本地目录,加载时本地优先。
// 本地已解压的离线包不依赖服务端(服务端删除后仍可加载运行)。
// Offline-package storage: download/extract/manually-import a webapp's offline package
// into the local directory; loading prefers local. A locally-extracted package does
// not depend on the server (still loads after the server removes it).
class WebAppStorage(private val context: Context) {
private val client = OkHttpClient()
private val rootDir: File by lazy { File(context.filesDir, "webapps") }
private fun appDir(id: String): File = File(rootDir, id)
// 本地是否已有离线包(解压目录含 index.html
// Whether a local offline package exists (extracted dir has index.html)
fun hasLocalPackage(id: String): Boolean =
File(appDir(id), "index.html").exists()
// 本地离线包入口 URL(file:// 绝对路径),无则 null
// canonicalPath 解析 /data/user/0 -> /data/data 符号链接,避免 WebView 的
// ERR_ACCESS_DENIED
// Local offline-package entry URL (file:// absolute path), null when absent.
// canonicalPath resolves the /data/user/0 -> /data/data symlink, avoiding the
// WebView ERR_ACCESS_DENIED
fun localIndexUrl(id: String): String? {
if (!hasLocalPackage(id)) return null
val f = File(appDir(id), "index.html")
val path = try {
f.canonicalPath
} catch (e: Exception) {
f.absolutePath
}
return "file://" + path
}
// 本地离线包版本号(解压目录里的 .version 文件),无则 null
// Local offline-package version (from the .version file), null when absent
fun getLocalVersion(id: String): String? {
val f = File(appDir(id), ".version")
return if (f.exists()) f.readText().trim().takeIf { it.isNotEmpty() } else null
}
// 删除本地离线包
// Delete the local offline package
fun deleteLocalPackage(id: String) {
val deleted = appDir(id).deleteRecursively()
Log.d(TAG, "deleteLocalPackage: id=$id deleted=$deleted")
}
// 下载并解压离线包(记录版本号),onProgress 回调 0-100
// Download and extract the offline package (record its version), onProgress 0-100
fun downloadAndExtract(id: String, packageUrl: String, version: String, onProgress: (Int) -> Unit): Boolean {
val tmp = File(context.cacheDir, "$id-offline.zip")
return try {
val request = Request.Builder().url(packageUrl).build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "downloadAndExtract: http ${resp.code} for $packageUrl")
return false
}
val body = resp.body ?: return false
val total = body.contentLength()
val input = body.byteStream()
FileOutputStream(tmp).use { output ->
val buf = ByteArray(8192)
var read = 0L
var n = input.read(buf)
while (n >= 0) {
output.write(buf, 0, n)
read += n
if (total > 0) onProgress(((read * 100) / total).toInt())
n = input.read(buf)
}
}
}
val ok = extractZip(tmp, appDir(id))
if (ok) writeVersion(id, version)
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "downloadAndExtract failed: ${e.message}")
tmp.delete()
false
}
}
// 手动导入离线包:从 uri 复制 zip 并解压(版本记为 manual
// Manually import an offline package: copy the zip from uri and extract (version "manual")
fun importPackage(id: String, uri: Uri): Boolean {
val tmp = File(context.cacheDir, "$id-import.zip")
return try {
val input = context.contentResolver.openInputStream(uri) ?: return false
FileOutputStream(tmp).use { output -> input.use { it.copyTo(output) } }
val ok = extractZip(tmp, appDir(id))
if (ok) writeVersion(id, "manual")
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "importPackage failed: ${e.message}")
tmp.delete()
false
}
}
// 记录本地离线包版本号(.version 文件)
// Record the local offline-package version (a .version file)
private fun writeVersion(id: String, version: String) {
try {
File(appDir(id), ".version").writeText(version)
} catch (e: Exception) {
Log.w(TAG, "writeVersion failed: ${e.message}")
}
}
// 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在
// Extract the zip into the destination (clear it first), verify index.html exists
private fun extractZip(zip: File, dest: File): Boolean {
return try {
dest.deleteRecursively()
dest.mkdirs()
ZipInputStream(zip.inputStream()).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
// 防 zip 路径穿越:规范化名称,丢弃含 ../ 的条目
// Zip-slip guard: normalize the name, drop entries with ../
val name = entry.name.replace('\\', '/')
if (name.contains("..")) {
entry = zis.nextEntry
continue
}
val outFile = File(dest, name)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { zis.copyTo(it) }
}
entry = zis.nextEntry
}
}
File(dest, "index.html").exists()
} catch (e: Exception) {
Log.e(TAG, "extractZip failed: ${e.message}")
false
}
}
companion object {
private const val TAG = "HearthWebAppStorage"
}
}
@@ -4,9 +4,46 @@ import android.util.Log
import top.yeij.hearth.app.AppRepository import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.card.Card import top.yeij.hearth.card.Card
import top.yeij.hearth.card.CardRepository import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaInfo
import top.yeij.hearth.media.MediaSessionSource import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebApp import top.yeij.hearth.webapp.WebApp
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
// 通知使用权访问接口:由 MainActivity 实现,JsBridge 通过它检查/申请授权,
// 保持 JsBridge 可 JVM 单测(不直接依赖 Context/NotificationManagerCompat
// Notification-access provider implemented by MainActivity; JsBridge uses it to
// check/request access, keeping JsBridge JVM-testable (no direct Context dependency)
interface NotificationAccessProvider {
fun isGranted(): Boolean
fun requestAccess()
}
// 系统亮度访问接口:由 MainActivity 实现(Settings.System + WRITE_SETTINGS 授权)
// System brightness provider implemented by MainActivity (Settings.System + WRITE_SETTINGS)
interface BrightnessProvider {
fun getSystemBrightness(): Int
fun setSystemBrightness(value: Int)
fun canWriteSettings(): Boolean
fun requestWriteSettings()
}
// 服务器地址存储接口:由 MainActivity 实现(SharedPreferences 持久化)
// Server URL storage provider implemented by MainActivity (SharedPreferences)
interface ServerUrlProvider {
fun getServerUrl(): 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,
@@ -16,9 +53,49 @@ class JsBridge(
private val appRepository: AppRepository? = null, private val appRepository: AppRepository? = null,
private val webAppRepository: WebAppRepository? = null, private val webAppRepository: WebAppRepository? = null,
private val cardRepository: CardRepository? = null, private val cardRepository: CardRepository? = null,
private val webAppContainer: WebAppContainer? = null,
private val webAppHost: WebAppHost? = null,
// 主线程执行器:View 操作必须切到主线程;null(单测)时同步执行
// Main-thread executor: view ops must run on main; null (unit tests) runs inline
private val postToMainThread: ((() -> Unit) -> Unit)? = null,
// 通知使用权访问接口(检查/申请授权),供设置页授权项使用
// Notification-access provider (check/request) for the settings permission item
private val notificationAccess: NotificationAccessProvider? = null,
// 系统亮度接口(读取/设置 + WRITE_SETTINGS 授权)
// System brightness provider (get/set + WRITE_SETTINGS grant)
private val brightness: BrightnessProvider? = null,
// 服务器地址存储接口
// Server URL storage provider
private val serverUrl: ServerUrlProvider? = null,
// 壁纸提供接口(base64
// Wallpaper provider (base64)
private val wallpaper: WallpaperProvider? = null,
// 标签面板回调:由 MainActivity 实现,弹出原生标签面板(PopupWindow,在内容
// WebView 之上,不占用预留高度)
// Tab panel callback implemented by MainActivity: shows a native tab panel
// (PopupWindow above the content WebView, no reserved height)
private val onShowTabPanel: (() -> Unit)? = null,
// 离线包存储(下载/解压/导入/本地检查)
// Offline-package storage (download/extract/import/local check)
private val storage: WebAppStorage? = null,
// 手动导入离线包回调:由 MainActivity 实现(SAF 选择 zip 后导入)
// Manual offline-package import callback implemented by MainActivity (SAF pick + import)
private val onImportOfflinePackage: ((String) -> Unit)? = null,
// 导出日志回调:由 MainActivity 实现,返回导出结果描述(文件名/错误)
// Export-log callback implemented by MainActivity, returns a result description
private val onExportLog: (() -> String)? = null,
) { ) {
private val gson = com.google.gson.Gson() private val gson = com.google.gson.Gson()
// 桌面 WebView 引用(媒体/进度推送用),setMediaListener 时保存
// Desktop WebView reference (for media/progress push), saved in setMediaListener
private var desktopWebViewRef: android.webkit.WebView? = null
// 清单内存缓存:openWebApp 首次拉取后缓存,后续复用避免重复 I/O
// In-memory manifest cache: cached after first fetch to avoid repeated I/O
@Volatile
private var manifestCache: List<WebApp>? = null
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun getDeviceInfo(): String { fun getDeviceInfo(): String {
Log.d("HearthBridge", "getDeviceInfo called") Log.d("HearthBridge", "getDeviceInfo called")
@@ -42,10 +119,240 @@ class JsBridge(
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
// 返回 H5 应用清单 JSON(无仓库时返回空数组) // 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用
// Return the H5 web app manifest JSON (empty array when no repository wired) // Return the H5 web app manifest JSON (empty array when no repository wired) and
// seed the in-memory cache for openWebApp reuse
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>()) fun fetchWebApps(): String {
val apps = manifest()
return gson.toJson(apps)
}
// 打开指定 id 的 webAPP:本地离线包优先 → 有离线包则下载解压(带进度)→ 否则远程加载
// Open a web app by id: prefer the local offline package -> download+extract (with
// progress) if it has an offline package -> otherwise load the remote URL
@android.webkit.JavascriptInterface
fun openWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
val app = manifest().firstOrNull { it.id == id }
if (app == null) {
Log.d("HearthBridge", "openWebApp: unknown id=$id")
return
}
val st = storage
// 1. 本地离线包优先(服务端删除后仍可用)
val localUrl = st?.localIndexUrl(id)
if (localUrl != null) {
openWebAppAt(container, host, id, localUrl, app.name, app.ua, app.scale)
return
}
// 2. 有离线包 → 后台下载解压(推送进度),完成后加载本地;失败回退远程
val pkg = app.offline
if (pkg != null && st != null) {
pushWebappProgress(id, 0)
Thread {
val fullUrl = resolveUrl(pkg)
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
val url = st.localIndexUrl(id) ?: app.url
openWebAppAt(container, host, id, url, app.name, app.ua, app.scale)
pushWebappProgress(id, 100)
}.start()
return
}
// 3. 无离线包 → 远程加载(WebView 自带加载进度)
openWebAppAt(container, host, id, app.url, app.name, app.ua, app.scale)
}
// 在主线程打开指定 url 的 webAPP 标签
// Open a webapp tab at the given url on the main thread
private fun openWebAppAt(container: WebAppContainer, host: WebAppHost, id: String, url: String, name: String, ua: String?, scale: Int?) {
onMain {
container.open(id, url, name)
host.openWebView(id, url, ua, scale)
host.syncTabs(container.tabs())
}
Log.d("HearthBridge", "openWebApp: id=$id url=$url ua=$ua scale=$scale")
}
// 手动导入离线包(由 MainActivity 触发 SAF 选择 zip
// Manually import an offline package (MainActivity triggers SAF to pick a zip)
@android.webkit.JavascriptInterface
fun importOfflinePackage(id: String) {
Log.d("HearthBridge", "importOfflinePackage: id=$id")
onImportOfflinePackage?.invoke(id)
}
// 本地是否已有离线包(用于列表显示「离线」标签)
// Whether a local offline package exists (for the list's "offline" badge)
@android.webkit.JavascriptInterface
fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false
// 导出日志到 Download 目录,返回结果描述(文件名或错误)
// Export the log to the Download directory, return a result description
@android.webkit.JavascriptInterface
fun exportLog(): String {
Log.d("HearthBridge", "exportLog called")
return onExportLog?.invoke() ?: "日志导出未实现"
}
// 本地离线包版本号(无则空字符串)
// Local offline-package version (empty string when absent)
@android.webkit.JavascriptInterface
fun getLocalVersion(id: String): String = storage?.getLocalVersion(id) ?: ""
// 删除本地离线包
// Delete the local offline package
@android.webkit.JavascriptInterface
fun deleteLocalPackage(id: String) {
Log.d("HearthBridge", "deleteLocalPackage: id=$id")
storage?.deleteLocalPackage(id)
}
// 强制更新离线包:删除本地后重新下载
// Force-update the offline package: delete local then re-download
@android.webkit.JavascriptInterface
fun updateOfflinePackage(id: String) {
Log.d("HearthBridge", "updateOfflinePackage: id=$id")
val st = storage ?: return
val app = manifest().firstOrNull { it.id == id } ?: return
val pkg = app.offline ?: return
st.deleteLocalPackage(id)
pushWebappProgress(id, 0)
Thread {
val fullUrl = resolveUrl(pkg)
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
pushWebappProgress(id, 100)
}.start()
}
// 推送 webapp 加载/下载进度给 H50-100
// Push webapp load/download progress to H5 (0-100)
private fun pushWebappProgress(id: String, progress: Int) {
val wv = desktopWebViewRef ?: return
wv.post {
wv.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $progress);",
null
)
}
}
// 离线包路径解析:绝对 URL 直接用,相对路径拼服务器根
// Resolve the offline-package path: absolute URL as-is, relative joined to the server root
private fun resolveUrl(relative: String): String {
if (relative.startsWith("http://") || relative.startsWith("https://")) return relative
val base = serverUrl?.getServerUrl()?.substringBeforeLast('/') ?: return relative
return "$base/$relative"
}
// 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏
// Close a tab by id: remove tab -> destroy WebView on main thread -> activate the
// first remaining tab -> sync the topbar
@android.webkit.JavascriptInterface
fun closeWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
onMain {
container.close(id)
val nextActiveId = container.activeTabId()
host.closeWebView(id)
nextActiveId?.let { host.switchWebView(it) }
host.syncTabs(container.tabs())
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
}
}
// 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏
// Switch the active tab: validate id exists, update state -> switch WebView
// visibility on main thread -> sync the topbar
@android.webkit.JavascriptInterface
fun switchTab(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
onMain {
if (container.switchTo(id)) {
host.switchWebView(id)
host.syncTabs(container.tabs())
}
}
Log.d("HearthBridge", "switchTab: id=$id")
}
// 返回已打开标签列表 JSONid/name/active
// Return the open tab list JSON (id/name/active)
@android.webkit.JavascriptInterface
fun listTabs(): String {
val container = webAppContainer ?: return "[]"
val tabs = container.tabs().map { tab ->
mapOf("id" to tab.id, "name" to tab.name, "active" to tab.active)
}
return gson.toJson(tabs)
}
// 当前激活标签回退(主线程执行 WebView 导航)
// Go back on the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webGoBack() {
val host = webAppHost ?: return
onMain { host.goBack() }
Log.d("HearthBridge", "webGoBack called")
}
// 当前激活标签前进(主线程执行 WebView 导航)
// Go forward on the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webGoForward() {
val host = webAppHost ?: return
onMain { host.goForward() }
Log.d("HearthBridge", "webGoForward called")
}
// 当前激活标签重载(主线程执行 WebView 导航)
// Reload the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webReload() {
val host = webAppHost ?: return
onMain { host.reload() }
Log.d("HearthBridge", "webReload called")
}
// 隐藏所有内容 WebView(把前台 webapp 丢到后台,标签状态保留)
// Hide all content WebViews (send the foreground webapp to background,
// keeping the tab state) — called when the user switches pages via the sidebar
@android.webkit.JavascriptInterface
fun hideWebApps() {
val host = webAppHost ?: return
onMain { host.hideAll() }
Log.d("HearthBridge", "hideWebApps called")
}
// 弹出原生标签面板(H5 顶栏标签按钮点击时调用)
// Show the native tab panel (called when the H5 topbar tab button is tapped)
@android.webkit.JavascriptInterface
fun showTabPanel() {
Log.d("HearthBridge", "showTabPanel called")
onShowTabPanel?.invoke()
}
// 清单内存缓存:首次拉取成功后缓存,后续复用(避免 openWebApp 重复 I/O
// 空结果(离线失败)不缓存,下次调用重试拉取,避免空清单被永久缓存
// In-memory manifest cache: cache only on a successful non-empty fetch (avoid
// repeated I/O); empty results (offline failure) are never cached so the next
// call retries instead of being permanently stuck with an empty manifest
private fun manifest(): List<WebApp> {
manifestCache?.let { return it }
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
return apps
}
// 将操作派发到主线程:未注入执行器(单测)时同步执行
// Dispatch to the main thread; run inline when no executor is injected (unit tests)
private fun onMain(block: () -> Unit) {
val post = postToMainThread
if (post == null) block() else post(block)
}
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡) // 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
// Return the home card catalog JSON (empty array when no repository wired; // Return the home card catalog JSON (empty array when no repository wired;
@@ -57,17 +364,151 @@ class JsBridge(
return gson.toJson(cards) return gson.toJson(cards)
} }
// 返回通知使用权是否已授权(未接入返回 false)
// Return whether notification access is granted (false when not wired)
@android.webkit.JavascriptInterface
fun getNotificationAccess(): Boolean = notificationAccess?.isGranted() ?: false
// 跳转系统「通知使用权」设置页,让用户为媒体卡授权
// Jump to the system notification-access settings page for media-card grant
@android.webkit.JavascriptInterface
fun requestNotificationAccess() {
Log.d("HearthBridge", "requestNotificationAccess called")
notificationAccess?.requestAccess()
}
// 返回系统亮度(0-255,未接入返回 -1)
// Return system brightness (0-255, -1 when not wired)
@android.webkit.JavascriptInterface
fun getSystemBrightness(): Int = brightness?.getSystemBrightness() ?: -1
// 设置系统亮度(0-255
// Set system brightness (0-255)
@android.webkit.JavascriptInterface
fun setSystemBrightness(value: Int) {
brightness?.setSystemBrightness(value)
}
// 是否已授予「修改系统设置」权限(WRITE_SETTINGS
// Whether the WRITE_SETTINGS permission is granted
@android.webkit.JavascriptInterface
fun canWriteSettings(): Boolean = brightness?.canWriteSettings() ?: false
// 跳转系统「修改系统设置」授权页
// Jump to the system WRITE_SETTINGS grant page
@android.webkit.JavascriptInterface
fun requestWriteSettings() {
brightness?.requestWriteSettings()
}
// 返回 webAPP 服务器地址(未接入返回空字符串)
// Return the webAPP server URL (empty string when not wired)
@android.webkit.JavascriptInterface
fun getServerUrl(): String = serverUrl?.getServerUrl() ?: ""
// 设置 webAPP 服务器地址(持久化 + 后续拉取使用)
// Set the webAPP server URL (persisted and used by subsequent fetches)
@android.webkit.JavascriptInterface
fun setServerUrl(url: String) {
Log.d("HearthBridge", "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 清单 + 卡片目录,返回 { added, removed, changed }
// Re-fetch the webAPP manifest + card catalog, return { added, removed, changed }
@android.webkit.JavascriptInterface
fun refreshManifest(): String {
val oldApps = manifestCache ?: emptyList<WebApp>()
manifestCache = null
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
cardRepository?.fetchCatalog()
// 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化)
// Diff: added / removed / changed (offline version or URL/icon changed)
val oldIds = oldApps.map { it.id }.toSet()
val newIds = apps.map { it.id }.toSet()
val added = (newIds - oldIds).size
val removed = (oldIds - newIds).size
val changed = apps.count { new ->
val old = oldApps.find { it.id == new.id }
old != null && (old.offlineVersion != new.offlineVersion ||
old.url != new.url || old.icon != new.icon)
}
Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)")
// 推送 H5 刷新列表(清 preload 缓存并重渲染)
// Push H5 to refresh the list (clear preload cache and re-render)
desktopWebViewRef?.post {
desktopWebViewRef?.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappUpdated();",
null
)
}
return gson.toJson(mapOf("added" to added, "removed" to removed, "changed" to changed))
}
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null" // 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null"
// Register media session listener; push callbacks to H5 (push "null" when info is 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) { fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
source.start { info -> mediaSourceRef = source
webView.post { desktopWebViewRef = webView
val json = info?.toJson() ?: "null" source.start { infos -> pushMedia(infos, webView) }
webView.evaluateJavascript( }
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
null // 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()
}
// 媒体播放控制(作用于指定索引的会话,对应 H5 当前滑到的卡片)
// Media transport controls (act on the session at the given index, matching
// the card the H5 is currently showing)
@android.webkit.JavascriptInterface
fun mediaPrevious(index: Int) {
mediaSourceRef?.previous(index)
}
@android.webkit.JavascriptInterface
fun mediaPlayPause(index: Int) {
mediaSourceRef?.playPause(index)
}
@android.webkit.JavascriptInterface
fun mediaNext(index: Int) {
mediaSourceRef?.next(index)
}
// 拖动进度条 seek 到指定位置(毫秒)
// Seek to the given position (ms) when dragging the progress bar
@android.webkit.JavascriptInterface
fun mediaSeekTo(index: Int, position: Long) {
mediaSourceRef?.seekTo(index, position)
}
// 跳转到指定会话的播放界面(封面点击)
// Jump to the session's playback UI (cover tap)
@android.webkit.JavascriptInterface
fun openMediaApp(index: Int) {
mediaSourceRef?.openMediaApp(index)
}
private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {
webView.post {
val json = gson.toJson(infos)
webView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
null
)
} }
} }
} }
@@ -0,0 +1,45 @@
package top.yeij.hearth.webview
import top.yeij.hearth.webapp.Tab
// webAPP 内容 WebView 宿主接口:由 MainActivity 实现,管理多 WebView 的生命周期、
// 可见性、导航,并把标签状态同步回桌面 H5 顶栏
// Web app content WebView host interface: implemented by MainActivity, managing
// multi-WebView lifecycle, visibility, and navigation, and syncing tab state
// back to the desktop H5 topbar
interface WebAppHost {
// 创建内容 WebView 加载 url 并显示(已存在则切换到该标签);ua 为清单声明的 UA,
// scale 为清单声明的初始缩放百分比(可为 null)
// Create a content WebView loading url and show it (switch if already open);
// ua is the manifest-declared UA, scale is the initial zoom percentage (nullable)
fun openWebView(id: String, url: String, ua: String?, scale: Int?)
// 销毁对应内容 WebView
// Destroy the corresponding content WebView
fun closeWebView(id: String)
// 切换可见内容 WebView(其它内容 WebView GONE
// Switch the visible content WebView (hide the others with GONE)
fun switchWebView(id: String)
// 当前可见 WebView 回退,无历史返回 false
// Go back on the visible WebView, false when no history
fun goBack(): Boolean
// 当前可见 WebView 前进,无历史返回 false
// Go forward on the visible WebView, false when no history
fun goForward(): Boolean
// 当前可见 WebView 重载
// Reload the visible WebView
fun reload()
// 将标签状态推送到桌面 H5 顶栏
// Push the tab state to the desktop H5 topbar
fun syncTabs(tabs: List<Tab>)
// 隐藏所有内容 WebView(把前台 webapp 丢到后台,标签状态保留)
// Hide all content WebViews (send the foreground webapp to background,
// keeping the tab state)
fun hideAll()
}
@@ -2,28 +2,80 @@ package top.yeij.hearth.webview
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.graphics.Color
import android.view.ViewGroup
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebViewClient import android.webkit.WebViewClient
// WebView 工厂:统一内核配置,桌面层挂到指定父容器,内容层按需创建
// WebView factory: unified kernel config; the desktop layer attaches to a given
// parent container, content layers are created on demand
class WebViewManager(private val activity: Activity) { class WebViewManager(private val activity: Activity) {
// 创建桌面 WebView(底层 H5)挂到 parent,加载内置 index.html
// Create the desktop WebView (bottom H5 layer) under parent, load bundled index.html
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
fun attach(bridge: JsBridge): WebView { fun attach(bridge: JsBridge, parent: ViewGroup): WebView {
val webView = WebView(activity) val webView = WebView(activity)
// 透明背景,露出原生壁纸 webView.setBackgroundColor(Color.TRANSPARENT)
// Transparent background to reveal native wallpaper configure(webView)
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT) webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
parent.addView(webView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
return webView
}
// 创建内容 WebView(webAPP 标签页),仅配置不加载、不挂载(由宿主管理)
// 透明背景:webAPP 未声明背景色时透出桌面壁纸,而非默认白底
// Create a content WebView (webapp tab), configured but not loaded/attached
// (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")
fun createContentWebView(ua: String?, scale: Int?): WebView {
val webView = WebView(activity)
webView.setBackgroundColor(Color.TRANSPARENT)
configure(webView)
// UA 解析:desktop/pc → PC UA;自定义字符串 → 直接用;null → 默认平板 UA
// UA resolution: desktop/pc -> desktop UA; custom string -> used as-is;
// null -> default tablet UA
webView.settings.userAgentString = when {
ua == null -> UA_TABLET
ua == "desktop" || ua == "pc" -> UA_DESKTOP
else -> ua
}
// scale 由 WebAppHost 在 onPageFinished 里通过 CSS zoom 实现(reflow 缩放),
// 此处不做缩放
// scale is applied by WebAppHost via CSS zoom in onPageFinished (reflow
// scaling); no scaling here
return webView
}
// 统一内核配置:JS、宽视口、DOM 存储,禁用缩放
// Common kernel config: JS, wide viewport, DOM storage, zoom disabled
@SuppressLint("SetJavaScriptEnabled")
private fun configure(webView: WebView) {
webView.settings.apply { webView.settings.apply {
javaScriptEnabled = true javaScriptEnabled = true
useWideViewPort = true useWideViewPort = true
loadWithOverviewMode = true loadWithOverviewMode = true
setSupportZoom(false) setSupportZoom(false)
domStorageEnabled = true domStorageEnabled = true
// Android 11 + targetSdk 30 默认禁用 file:// 访问,本地离线包需要显式开启
// Android 11 + targetSdk 30 disables file:// by default; local offline
// packages need it explicitly enabled
allowFileAccess = true
allowContentAccess = true
} }
webView.addJavascriptInterface(bridge, "HearthBridge") }
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html") companion object {
activity.setContentView(webView) // PC 版 UAdesktop/pc
return webView private const val UA_DESKTOP =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
// 安卓平板 UA(默认)
private const val UA_TABLET =
"Mozilla/5.0 (Linux; Android 13; SM-X700 Build/TP1A.220624.014) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
} }
} }
@@ -50,4 +50,19 @@ class CardRepositoryTest {
assertEquals("time", cards[0].id) assertEquals("time", cards[0].id)
dir.deleteRecursively() dir.deleteRecursively()
} }
@Test
fun fetchCatalog_malformedBody_returnsBuiltin_andDoesNotCache() {
val dir = File(System.getProperty("java.io.tmpdir"), "c4")
val cm = CacheManager(dir)
val http = object : HttpClient { override fun get(url: String) = "{broken json" }
val repo = CardRepository(http, cm, "http://fake")
// 畸形 body 不崩溃、回退内置 time 卡,且不得写入缓存
// Malformed body: no crash, fall back to builtin time card, and must not be cached
val cards = repo.fetchCatalog()
assertEquals(1, cards.size)
assertEquals("time", cards[0].id)
assertEquals(null, cm.load("card-catalog"))
dir.deleteRecursively()
}
} }
@@ -58,4 +58,29 @@ class WebAppContainerTest {
c.close("b") c.close("b")
assertEquals("a", c.tabs().first { it.active }.id) assertEquals("a", c.tabs().first { it.active }.id)
} }
@Test
fun open_withName_recordsDisplayName() {
val c = WebAppContainer()
c.open("a", "http://x/a", "云音乐")
assertEquals("云音乐", c.tabs()[0].name)
}
@Test
fun activeTabId_returnsActiveId_orNullWhenEmpty() {
val c = WebAppContainer()
assertEquals(null, c.activeTabId())
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertEquals("b", c.activeTabId())
}
@Test
fun switchTo_unknownId_keepsCurrentActiveAndReturnsFalse() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertTrue(!c.switchTo("unknown"))
assertEquals("b", c.tabs().first { it.active }.id)
}
} }
@@ -41,4 +41,19 @@ class WebAppRepositoryTest {
assertTrue(repo.fetchManifest().isEmpty()) assertTrue(repo.fetchManifest().isEmpty())
dir.deleteRecursively() dir.deleteRecursively()
} }
@Test
fun fetchManifest_malformedBody_returnsEmpty_andDoesNotCache() {
val dir = File(System.getProperty("java.io.tmpdir"), "w4")
val cm = CacheManager(dir)
val http = object : HttpClient {
override fun get(url: String) = "{not valid json"
}
val repo = WebAppRepository(http, cm, "http://fake")
// 畸形 body 不崩溃、返回空,且不得写入缓存
// Malformed body: no crash, empty result, and must not be cached
assertTrue(repo.fetchManifest().isEmpty())
assertEquals(null, cm.load("webapp-manifest"))
dir.deleteRecursively()
}
} }
@@ -1,12 +1,35 @@
package top.yeij.hearth.webview package top.yeij.hearth.webview
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import top.yeij.hearth.card.CardRepository import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.cache.CacheManager import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.webapp.HttpClient import top.yeij.hearth.webapp.HttpClient
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import java.io.File import java.io.File
// 记录宿主方法调用,用于验证 JsBridge → WebAppHost 的接线
// Record host method calls to verify JsBridge -> WebAppHost wiring
private class FakeHost : WebAppHost {
var openedId: String? = null
var openedUrl: String? = null
val closedIds = mutableListOf<String>()
var switchedId: String? = null
var syncedTabs: List<Tab>? = null
var backCalls = 0
override fun openWebView(id: String, url: String) { openedId = id; openedUrl = url }
override fun closeWebView(id: String) { closedIds.add(id) }
override fun switchWebView(id: String) { switchedId = id }
override fun goBack(): Boolean { backCalls++; return false }
override fun goForward(): Boolean = false
override fun reload() {}
override fun syncTabs(tabs: List<Tab>) { syncedTabs = tabs }
}
class JsBridgeTest { class JsBridgeTest {
@Test @Test
fun getDeviceInfo_returnsJsonWithDarkMode() { fun getDeviceInfo_returnsJsonWithDarkMode() {
@@ -38,4 +61,133 @@ class JsBridgeTest {
assertTrue(json.contains("\"id\":\"time\"")) assertTrue(json.contains("\"id\":\"time\""))
dir.deleteRecursively() dir.deleteRecursively()
} }
@Test
fun listTabs_withContainer_returnsJsonWithNameAndActive() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true, webAppContainer = container)
val json = bridge.listTabs()
assertTrue(json.contains("\"id\":\"a\""))
assertTrue(json.contains("\"name\":\"云音乐\""))
assertTrue(json.contains("\"active\":true"))
}
@Test
fun listTabs_withoutContainer_returnsEmptyArray() {
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
assertTrue(bridge.listTabs() == "[]")
}
@Test
fun openWebApp_findsUrlAndName_opensTabAndSyncs() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-open")
val http = object : HttpClient {
override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val container = WebAppContainer()
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo, webAppContainer = container, webAppHost = host,
)
bridge.openWebApp("a")
assertEquals("a", host.openedId)
assertEquals("http://x/a", host.openedUrl)
assertEquals(1, container.tabs().size)
assertEquals("云音乐", container.tabs()[0].name)
assertEquals(1, host.syncedTabs?.size)
dir.deleteRecursively()
}
@Test
fun closeWebApp_destroysWebView_removesTab_andSyncs() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = container, webAppHost = host,
)
bridge.closeWebApp("a")
assertTrue(host.closedIds.contains("a"))
assertTrue(container.tabs().isEmpty())
assertEquals(0, host.syncedTabs?.size)
}
@Test
fun webGoBack_delegatesToHost() {
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = WebAppContainer(), webAppHost = host,
)
bridge.webGoBack()
assertEquals(1, host.backCalls)
}
@Test
fun openWebApp_defersViewOpsToMainThread() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-marshal")
val http = object : HttpClient {
override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val container = WebAppContainer()
val host = FakeHost()
val posted = mutableListOf<() -> Unit>()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo, webAppContainer = container, webAppHost = host,
postToMainThread = { posted.add(it) },
)
bridge.openWebApp("a")
assertEquals(0, container.tabs().size)
assertEquals(null, host.openedId)
assertEquals(1, posted.size)
posted.forEach { it() }
assertEquals(1, container.tabs().size)
assertEquals("a", host.openedId)
assertEquals("http://x/a", host.openedUrl)
dir.deleteRecursively()
}
@Test
fun switchTab_unknownId_doesNotTouchHost() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = container, webAppHost = host,
)
bridge.switchTab("unknown")
assertEquals(null, host.switchedId)
assertEquals("a", container.activeTabId())
}
@Test
fun fetchWebApps_doesNotCacheEmptyManifest() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-empty-manifest")
var online = false
val http = object : HttpClient {
override fun get(url: String) =
if (online) """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}""" else null
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo,
)
// 首次离线:返回空,且不得缓存空清单
// First call offline: empty result must not be cached
assertEquals("[]", bridge.fetchWebApps())
// 联网后再次拉取:不得命中空缓存,应返回应用
// Second call online: must not hit the empty cache, should return apps
online = true
val json = bridge.fetchWebApps()
assertTrue(json.contains("\"id\":\"a\""))
dir.deleteRecursively()
}
} }
+122
View File
@@ -0,0 +1,122 @@
# Hearth 启动器 · 开发日志
> 项目:HearthAndroid 启动器)· 包名 `top.yeij.hearth`
> 日期:2026-08-16
## 1. 项目概述
面向瑞芯微 RK3566 横屏开发板(立创·泰山派)的 Android 启动器(Home)。
核心架构:**界面全 H5 渲染,原生只当「能力层」**,通过 JS Bridge`window.HearthBridge`)双向调用,桌面 UI、卡片、webAPP 均可远程下发、动态扩展,无需重装 APK。
目标平台:Android 11API 30)· 横屏 800×480 · 高 DPI(自适应系统 density)· `minSdk=30` 不做低版本兼容。
## 2. 环境搭建(arm64 工具链,本次最大障碍)
本机是 aarch64 容器,而 Android SDK 的构建工具(aapt2 等)官方只发布 x86_64 Linux 版。搭建过程:
| 组件 | 方案 | 来源 |
|---|---|---|
| JDK 17 | apt | 清华源 |
| Gradle 8.9 | 二进制包 | 腾讯镜像 |
| Android SDK platform-35 + build-tools 34.0.0 | 手动下载 | 腾讯镜像(dl.google.com 不通,且镜像无 android-34 正式版,故 `compileSdk=35` |
| **arm64 aapt2/zipalign/split-select** | drop-in 替换 | Commit451/android-arm-build-toolsgh-proxy.com 加速) |
| 依赖仓库 | 阿里云 google/central 镜像 | `maven.aliyun.com` |
**关键结论**:官方 Google Maven 无 arm64 Linux 版 aapt2Issue #227219818),只能社区构建。通过 `android.aapt2FromMavenOverride` 指向 arm64 aapt2 绕开 Google Maven 的 x86_64 下载。
另:AGP 首次构建会卡死在 `dl.google.com` 的 SDK 自动下载(SYN_SENT 挂起),需 `android.builder.sdkDownload=false` 关闭。
## 3. 设计决策
- **全 H5 渲染**:桌面 = 全屏 WebView + H5,原生只提供查应用/启动/媒体状态/下载缓存等能力。
- **UI 风格 Miuix / HyperOS 4 视觉**(纯 CSS 还原,非 Compose 库):
- **壁纸透明**Activity `showWallpaper` + WebView `setBackgroundColor(TRANSPARENT)` + H5 `html/body` 透明。
- **柔光玻璃**(HyperOS 4 设计):卡片用 `backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1)` + 半透明背景 + 1px 描边(参数参考 Miuix `miuix-blur`saturation 1.2 / brightness +0.05 / contrast 1.1)。
- **卡片插件化**:每个卡片 = 独立 HTML + manifest(声明数据源/权限),可远程下发。
- **webAPP 多标签**:每个标签 = 一个独立 WebView(`Map<String, WebView>`),内容 WebView topMargin 44dp 露出 H5 顶栏。
- **媒体卡权限方案**`MEDIA_CONTENT_CONTROL` 是 signature|privileged 权限普通 APK 拿不到,改用 **NotificationListenerService**(用户授权"通知使用权"),`getActiveSessions(ComponentName)` 传 listener 组件作为授权凭据。
- **编译环境适配**`compileSdk=35` + AGP `8.6.1`(匹配 arm64 aapt2 8.6.x)。
## 4. 实现过程(14 个任务,TDD + 逐任务 review
| 阶段 | 任务 |
|---|---|
| 原生骨架 | 项目骨架+窗口管理 → WebViewManager+JsBridge → AppRepository |
| 桌面+列表 | H5 桌面壳 → 安卓APP列表 → CacheManager+WebApp清单 → H5应用列表 |
| 多标签 | WebAppContainer(标签状态数据层)→ 顶栏 H5 + tabs 纯函数 |
| 卡片系统 | CardRepository+首页三栏 → MediaSessionSource+媒体卡 |
| 收尾 | 设置页 → 集成收尾(接线+NotificationListenerService)→ 补充 webAPP 多标签 JS Bridge |
测试:Kotlin 单测 32 个(AppRepository/JsBridge/WebAppRepository/WebAppContainer/CacheManager 等)+ H5 node:test 8 个(cards.js 布局降级 / tabs.js 标签状态),全绿。
## 5. 关键问题与解决
1. **aapt2 arm64**:官方无 → Commit451 社区构建 drop-in 替换(见 §2)。
2. **JS 桥线程 vs 主线程**CriticalTask 14 review 发现):`@JavascriptInterface` 方法在 JavaBridge 后台线程运行,直接操作 View 会抛 `CalledFromWrongThreadException`。解决:所有 View 操作 `postToMainThread`(注入 `desktopWebView.post`marshal 到主线程。
3. **跨线程竞态**(最终 review 发现):WebAppContainer 的 `open/close/switchTo` 在主线程写、`listTabs` 在桥线程读同一 `mutableListOf`。解决:状态方法全部 `@Synchronized`
4. **innerHTML XSS**applist/webapplist/topbar 用 innerHTML 拼用户数据,桌面 WebView 持有桥权限可被注入。解决:全部改 `createElement` + `textContent`/`dataset`
5. **首页空卡片**`fetchCards` 在仓库未接线时返回 `"[]"``layout([])` 渲染空。解决:home.js 兜底补内置 time 卡。
6. **媒体卡权限**:见 §3NotificationListenerService 方案。
## 6. 遗留项(deferred,不影响 merge,后续处理)
- 真机验证:无 adb 设备,壁纸/柔光玻璃/媒体卡/多标签导航均未真机冒烟。
- `drawableToBase64` 未 recycle bitmap`timeTicker` setInterval 未清理。
- 沉浸模式仅在 onCreate 设置,返回前台未重设(建议 onResume)。
- 内容 WebView 无 onPause/onResume 生命周期同步。
- 设置页 Switch/Slider/版本号静态,交互未接。
- webAPP 服务器地址、卡片目录地址为占位常量,需真机配置。
- 二期:沉浸首页 freeform 小窗(需固件 `enable_freeform_support`)。
## 7. 分支与提交
开发走 `dev` 分支(`main` 为发布分支)。设计文档 `docs/superpowers/specs/`、实现计划 `docs/superpowers/plans/`、本日志 `docs/`
## 8. 真机调试与 UI 迭代(宿主机 OnePlus PLK110
### 8.1 真机环境
- 通过 SSH 连宿主机 Termux`aska@127.0.0.1:2022`),KernelSU root`logcat` 抓日志排查。
- APK 用容器内 Python `http.server` 分发(`http://localhost:8080/Hearth-debug.apk`)。
- 真机是 Android 16API 36)手机,非目标板(RK3566/Android 11),但能验证 H5 逻辑与大部分原生行为。
### 8.2 关键崩溃修复
1. **黑屏(实为崩溃)**`enterImmersive()``setContentView()` 之前调 `window.insetsController`,此时 DecorView 未创建(null)→ NPE。日志堆栈 `Attempt to invoke WindowInsetsController on a null object reference`。修复:把 `enterImmersive()` 移到 `setupWebView()`(内含 setContentView)之后。
2. **闪退**`MediaSessionSource` 未授权「通知使用权」时 `addOnActiveSessionsChangedListener``SecurityException: Missing permission to control media`。修复:`start()` 内 try-catch 降级 + 设置页加授权引导项(跳 `ACTION_NOTIFICATION_LISTENER_SETTINGS`)。
### 8.3 媒体卡完善
- **封面**`METADATA_KEY_ART` → 128px JPEG base64;加**封面缓存**(切歌瞬间 ART 短暂为 null,缓存兜底避免横划后封面消失)。
- **控制按钮**:上一首/暂停/下一首(`transportControls`),**按索引**作用于当前横划到的会话。
- **实时歌词**:网易云歌词实时更新在**通知标题**(不在 MediaSession metadata),`NotificationListenerService` 监听 `CATEGORY_TRANSPORT` 通知的 `EXTRA_TITLE`,标题优先用通知实时歌词。
- **进度条 + 时长 + 拖动 seek**:轮询兜底(registerCallback 在歌词场景不触发);进度条 `box-sizing:border-box``padding` 吃掉 `height` 导致 bar 高度 0 的坑;拖动 seek 调 `transportControls.seekTo`
- **多会话滑动**:左右横划切换(方向过渡动画),多个媒体通知(音乐/听书/视频)并存。
### 8.4 玻璃效果与壁纸
- **壁纸进 H5**`backdrop-filter` 只能模糊 WebView 内部内容,而壁纸原来在 WebView 之外(透明透出),故无模糊。原生 `getWallpaper()` 把壁纸转 base64 作 body 背景,卡片才能真正模糊壁纸。
- **动态壁纸降级**:检测 `wallpaperInfo != null` 返回空,H5 保持透明透出动态壁纸,玻璃模糊降级为普通半透明。
- **玻璃开关**:设置页开关,默认关(普通半透明),开 = 液态玻璃(磨砂模糊 + 边缘高光)。
### 8.5 设置页交互
主题三态(跟随/浅色/深色,`data-theme` 覆盖 `prefers-color-scheme`)、背景遮罩(夜间,开关+明暗度)、系统亮度(`WRITE_SETTINGS` 授权)、webAPP 服务器地址(SharedPreferences)、检查更新(重拉清单);滑块统一 touch 拖动。
### 8.6 webapp 多标签
- **原生标签面板**:H5 面板是桌面 WebView 底层元素,被上层内容 WebView 遮住(Android View 层级无法用 z-index 跨越)。改 **PopupWindow 原生面板**覆盖显示,内容 WebView 只预留顶栏高度(不压 webapp)。
- **切页隐藏 webapp**`hideWebApps()`(内容 WebView GONE,标签保留)+ `body.webapp-active` 隐藏列表内容,切回自动恢复。
- **内容 WebView** 透明背景(未声明背景色的 webapp 透出壁纸)。
### 8.7 性能
- **页面预加载**:启动后后台并行拉取 APP 列表/webAPP 清单/卡片目录缓存,切页直接渲染。
- **媒体卡增量更新**:歌词实时变化时只更新媒体卡文字,不重建整个首页(之前每次歌词变化都重建导致卡顿)。
## 9. 仍在遗留
- 真机为手机(Android 16),目标板 RK3566/Android 11 尚未验证(壁纸/玻璃/沉浸式/媒体卡的板子适配)。
- 沉浸模式未在 onResume 重设;内容 WebView 无 onPause/onResume 同步。
- 二期:沉浸首页 freeform 小窗(需固件 `enable_freeform_support`)。
- webAPP/卡片目录默认占位地址,真机部署时配置真实服务器。
@@ -274,7 +274,7 @@ cards/<card-id>/
| 权限 | 用途 | 授予方式 | | 权限 | 用途 | 授予方式 |
|---|---|---| |---|---|---|
| `QUERY_ALL_PACKAGES` | 查询已装应用列表 | launcher 豁免,声明即生效 | | `QUERY_ALL_PACKAGES` | 查询已装应用列表 | launcher 豁免,声明即生效 |
| `MEDIA_CONTENT_CONTROL` | 读取媒体状态 | 运行时 + 通知监听授权(用户手动) | | `BIND_NOTIFICATION_LISTENER_SERVICE` | 读取媒体通知(媒体卡数据源) | 用户手动授权「通知使用权」 |
| `READ_CALENDAR` | 日历卡片数据 | 运行时授权 | | `READ_CALENDAR` | 日历卡片数据 | 运行时授权 |
| `INTERNET` | 拉取 webAPP/卡片 | 声明即生效 | | `INTERNET` | 拉取 webAPP/卡片 | 声明即生效 |