feat: settings interactions (theme/mask/brightness/server-url), svg icons, seconds display, page transition

This commit is contained in:
2026-08-16 18:25:28 +08:00
parent e2a289a4b0
commit c9dcb88647
11 changed files with 510 additions and 29 deletions
+64 -5
View File
@@ -9,13 +9,26 @@ 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 */
@@ -35,7 +48,16 @@ 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;
@@ -50,7 +72,8 @@ body {
cursor: pointer; cursor: pointer;
} }
.rail-item .ico { font-size: 20px; } .rail-item .ico { width: 24px; height: 24px; }
.rail-item .ico svg { width: 100%; height: 100%; display: block; }
.rail-item .lbl { font-size: 11px; } .rail-item .lbl { font-size: 11px; }
.rail-item.active { background: var(--accent); color: #fff; } .rail-item.active { background: var(--accent); color: #fff; }
@@ -58,7 +81,18 @@ body {
.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 {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */ /* 首页三栏布局:三等分纵向列,卡片向下堆叠 */
/* Home three-column layout: three equal vertical columns, cards stack downward */ /* Home three-column layout: three equal vertical columns, cards stack downward */
@@ -99,6 +133,15 @@ body {
line-height: 1; line-height: 1;
} }
/* 秒显:小号、弱化,紧跟时:分之后 */
/* Seconds: small and dim, trailing the HH:MM */
.time-card .sec {
font-size: 24px;
font-weight: 300;
color: var(--text-dim);
margin-left: 2px;
}
.time-card .sub { .time-card .sub {
margin-top: 8px; margin-top: 8px;
font-size: 14px; font-size: 14px;
@@ -167,6 +210,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 {
@@ -347,6 +393,19 @@ body {
.st-item .lbl { font-size: 15px; } .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;
}
/* 右侧箭头:弱化色 */ /* 右侧箭头:弱化色 */
/* Trailing arrow: dim color */ /* Trailing arrow: dim color */
.st-item .arrow { .st-item .arrow {
@@ -400,7 +459,7 @@ body {
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 60%; width: var(--fill, 60%);
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
} }
+13 -1
View File
@@ -8,8 +8,10 @@
--accent: #ff6900; --accent: #ff6900;
} }
/* 系统深色:仅在未强制浅色时生效(跟随系统) */
/* 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 +19,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;
}
+6
View File
@@ -6,8 +6,10 @@
<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>
</head> </head>
<body> <body>
<div id="wallpaper-dim"></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,6 +24,7 @@
</main> </main>
</div> </div>
<script src="js/bridge.js"></script> <script src="js/bridge.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>
@@ -48,6 +51,9 @@
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();
</script> </script>
</body> </body>
</html> </html>
+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';
},
};
})();
+12 -7
View File
@@ -31,13 +31,13 @@ window.updateHomeCards = async function () {
startTimeCardTicker(); startTimeCardTicker();
}; };
// 内置大字时间卡:当前时间 + 日期 // 内置大字时间卡:当前时间(时:分 + 小号秒)+ 日期
// Builtin big time card: current time + date // Builtin big time card: current time (HH:MM + small seconds) + 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="big"><span class="hm">${fmtTime(now)}</span><span class="sec">${fmtSec(now)}</span></div><div class="sub">${fmtDate(now)}</div>`;
return el; return el;
} }
@@ -56,13 +56,17 @@ function fmtTime(d) {
return `${hh}:${mm}`; return `${hh}:${mm}`;
} }
function fmtSec(d) {
return `:${String(d.getSeconds()).padStart(2, '0')}`;
}
function fmtDate(d) { function fmtDate(d) {
const days = ['日', '一', '二', '三', '四', '五', '六']; const days = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getMonth() + 1}${d.getDate()}日 周${days[d.getDay()]}`; return `${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,9 +74,10 @@ 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('.hm').textContent = fmtTime(now);
card.querySelector('.sec').textContent = fmtSec(now);
card.querySelector('.sub').textContent = fmtDate(now); card.querySelector('.sub').textContent = fmtDate(now);
}, 10000); }, 1000);
} }
// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局 // 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局
+192 -6
View File
@@ -1,26 +1,112 @@
// 设置页:Miuix Preference 分组 + 玻璃条目(通知使用权为可用交互项) // 设置页:Miuix Preference 分组 + 玻璃条目,含主题/遮罩/亮度/权限/网络交互
// Settings page: Miuix Preference groups + glass items (notification access is interactive) // Settings page: Miuix Preference groups + glass items with interactions for
// theme / mask / brightness / permission / network
window.loadSettings = function () { window.loadSettings = function () {
const page = document.querySelector('[data-page="settings"]'); const page = document.querySelector('[data-page="settings"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1'; if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `<div class="st-wrap"> page.innerHTML = `<div class="st-wrap">
<div class="st-group">显示</div> <div class="st-group">显示</div>
<div class="st-item"><span class="lbl">跟随系统主题</span><span class="sw on"><i></i></span></div> <div class="st-item" id="theme-item">
<div class="st-item"><span class="lbl">屏幕亮度</span><span class="slider"><i></i></span></div> <span class="lbl">主题</span>
<span class="arrow" id="theme-state">跟随系统</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-group">权限</div>
<div class="st-item" id="notif-access"> <div class="st-item" id="notif-access">
<span class="lbl">通知使用权(媒体卡)</span> <span class="lbl">通知使用权(媒体卡)</span>
<span class="arrow" id="notif-access-state"></span> <span class="arrow" id="notif-access-state"></span>
</div> </div>
<div class="st-group">网络</div> <div class="st-group">网络</div>
<div class="st-item"><span class="lbl">webAPP 服务器地址</span><span class="arrow"></span></div> <div class="st-item" id="server-url-item">
<div class="st-item"><span class="lbl">检查更新</span><span class="arrow"></span></div> <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-group">关于</div>
<div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div> <div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div>
</div>`; </div>`;
setupTheme(page);
setupMask(page);
setupNotificationAccess(page); setupNotificationAccess(page);
setupBrightness(page);
setupServerUrl(page);
setupCheckUpdate(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); // 主题切换后重算遮罩项的显示与状态
};
}
// 背景遮罩:开关 + 明暗度(仅夜间主题显示)
// 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();
};
slider.onclick = (e) => {
const rect = slider.getBoundingClientRect();
const a = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
window.mask.setAlpha(Math.round(a * 100) / 100);
refresh();
};
}
// 通知使用权授权项:异步查状态,未授权点击跳转系统授权页 // 通知使用权授权项:异步查状态,未授权点击跳转系统授权页
// Notification-access item: async state check, tap to open the system grant page // Notification-access item: async state check, tap to open the system grant page
function setupNotificationAccess(page) { function setupNotificationAccess(page) {
@@ -34,3 +120,103 @@ function setupNotificationAccess(page) {
bridge.call('requestNotificationAccess').catch(() => {}); 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 + '%';
sliderEl.onclick = (e) => {
e.stopPropagation();
const rect = sliderEl.getBoundingClientRect();
const v = Math.round(Math.min(255, Math.max(0, (e.clientX - rect.left) / rect.width)) * 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;') + '">';
const input = item.querySelector('#server-url-input');
input.focus();
const save = () => {
bridge.call('setServerUrl', input.value.trim()).then(() => {
editing = false;
refresh();
}).catch(() => {});
};
input.onkeydown = (e) => { if (e.key === 'Enter') save(); };
input.onblur = () => { 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 n = 0;
try { n = JSON.parse(result).count || 0; } catch (e) {}
state.textContent = n > 0 ? '已更新 ' + n + ' 个 ' : '无更新 ';
}).catch(() => {
state.textContent = '失败 ';
});
};
}
+19 -8
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,10 +24,11 @@ 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));
// 非首页时侧边栏顶部显示小时间 // 非首页时侧边栏顶部显示小时间(容器始终占位,仅切换可见性避免撑动图标)
// Show the small clock at the sidebar top when not on home // 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 === 'home') window.updateHomeCards && window.updateHomeCards();
if (id === 'applist') window.loadAppList && window.loadAppList(); if (id === 'applist') window.loadAppList && window.loadAppList();
if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList(); if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList();
+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());
})();
@@ -3,6 +3,7 @@ package top.yeij.hearth
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
@@ -25,8 +26,10 @@ import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.Tab import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webview.BrightnessProvider
import top.yeij.hearth.webview.JsBridge import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.NotificationAccessProvider import top.yeij.hearth.webview.NotificationAccessProvider
import top.yeij.hearth.webview.ServerUrlProvider
import top.yeij.hearth.webview.WebAppHost import top.yeij.hearth.webview.WebAppHost
import top.yeij.hearth.webview.WebViewManager import top.yeij.hearth.webview.WebViewManager
import java.io.File import java.io.File
@@ -63,6 +66,47 @@ class MainActivity : Activity() {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) 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.setManifestUrl(url)
}
}
private lateinit var webAppRepository: WebAppRepository
private lateinit var mediaSource: MediaSessionSource private lateinit var mediaSource: MediaSessionSource
private lateinit var root: FrameLayout private lateinit var root: FrameLayout
private lateinit var desktopWebView: WebView private lateinit var desktopWebView: WebView
@@ -96,18 +140,21 @@ class MainActivity : Activity() {
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
val cache = CacheManager(File(filesDir, "cache")) val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient() val http = OkHttpHttpClient()
webAppRepository = WebAppRepository(http, cache, serverUrlProvider.getServerUrl())
bridge = JsBridge( 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(http, cache, MANIFEST_URL), webAppRepository = webAppRepository,
cardRepository = CardRepository(http, cache, CATALOG_URL), cardRepository = CardRepository(http, cache, CATALOG_URL),
webAppContainer = webAppContainer, webAppContainer = webAppContainer,
webAppHost = webAppHost, webAppHost = webAppHost,
postToMainThread = { desktopWebView.post(it) }, postToMainThread = { desktopWebView.post(it) },
notificationAccess = notificationAccess, notificationAccess = notificationAccess,
brightness = brightnessProvider,
serverUrl = serverUrlProvider,
) )
root = FrameLayout(this) root = FrameLayout(this)
setContentView(root) setContentView(root)
@@ -27,10 +27,17 @@ 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 manifestUrl: String,
) { ) {
private val gson = Gson() private val gson = Gson()
// 更新清单地址(设置页修改服务器地址后调用)
// Update the manifest URL (called after the settings page changes the server URL)
fun setManifestUrl(url: String) {
manifestUrl = url
Log.d(TAG, "setManifestUrl: $url")
}
fun fetchManifest(): List<WebApp> { fun fetchManifest(): List<WebApp> {
val body = http.get(manifestUrl) val body = http.get(manifestUrl)
if (body != null) { if (body != null) {
@@ -19,6 +19,22 @@ interface NotificationAccessProvider {
fun requestAccess() 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)
}
class JsBridge( class JsBridge(
private val deviceWidthPx: Int, private val deviceWidthPx: Int,
private val deviceHeightPx: Int, private val deviceHeightPx: Int,
@@ -35,6 +51,12 @@ class JsBridge(
// 通知使用权访问接口(检查/申请授权),供设置页授权项使用 // 通知使用权访问接口(检查/申请授权),供设置页授权项使用
// Notification-access provider (check/request) for the settings permission item // Notification-access provider (check/request) for the settings permission item
private val notificationAccess: NotificationAccessProvider? = null, 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,
) { ) {
private val gson = com.google.gson.Gson() private val gson = com.google.gson.Gson()
@@ -210,6 +232,55 @@ class JsBridge(
notificationAccess?.requestAccess() 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)
}
// 重新拉取 webAPP 清单 + 卡片目录,返回 { count }
// Re-fetch the webAPP manifest + card catalog, return { count }
@android.webkit.JavascriptInterface
fun refreshManifest(): String {
manifestCache = null
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
val cards = cardRepository?.fetchCatalog() ?: emptyList<Card>()
Log.d("HearthBridge", "refreshManifest: ${apps.size} apps, ${cards.size} cards")
return gson.toJson(mapOf("count" to apps.size))
}
// 注册媒体会话监听,回调推送给 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)
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) { fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {