Skip to content

Commit 069c8db

Browse files
authored
修复字幕翻译窗口暗色模式样式,保持窗口外层透明,同时让字幕条运行时背景正确使用暗色渐变 (#1657)
* 修复字幕翻译窗口暗色模式样式,保持窗口外层透明,同时让字幕条运行时背景正确使用暗色渐变 * 新增 MutationObserver 监听 data-theme,并用去重逻辑避免和自定义事件重复刷新 * 在独立字幕页 templates/subtitle.html (line 7) 加载了 /static/theme-manager.js,放在字幕脚本之前。这样 /subtitle 打开时如果 localStorage.neko-dark-mode=true,会先初始化 data-theme="dark",字幕背景内联渐变和透明暗色覆盖都能立刻生效
1 parent 7137999 commit 069c8db

5 files changed

Lines changed: 162 additions & 2 deletions

File tree

static/css/dark-mode.css

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,9 @@ html #chat-container {
302302

303303
/* ===== 暗色模式:字幕显示区域 ===== */
304304
[data-theme="dark"] #subtitle-display {
305-
background: linear-gradient(135deg, rgba(42, 123, 196, 0.92) 0%, rgba(42, 123, 196, 0.88) 50%, rgba(60, 145, 220, 0.92) 100%);
305+
background: linear-gradient(135deg, rgba(18, 29, 45, 0.95) 0%, rgba(22, 45, 68, 0.91) 52%, rgba(30, 74, 108, 0.95) 100%);
306306
border-color: rgba(255, 255, 255, 0.25);
307-
box-shadow: 0 8px 32px rgba(42, 123, 196, 0.5),
307+
box-shadow: 0 8px 32px rgba(6, 12, 24, 0.5),
308308
0 0 0 1px rgba(255, 255, 255, 0.2) inset;
309309
}
310310

@@ -670,6 +670,13 @@ html #chat-container {
670670
}
671671
}
672672

673+
/* 独立字幕窗口是透明 BrowserWindow;暗色移动端黑底规则会因 600px 窗口宽度命中。
674+
这里在黑底规则之后重新钉住 html/body 透明,避免字幕翻译窗口露出整块黑底。 */
675+
html[data-theme="dark"].subtitle-window-host,
676+
html[data-theme="dark"].subtitle-window-host body.subtitle-window-host {
677+
background: transparent !important;
678+
}
679+
673680
/* ===========================================================
674681
子页面通用暗色模式覆盖
675682
适用于 character_card_manager, api_key_settings, memory_browser,

static/subtitle-shared.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,9 +431,20 @@
431431
};
432432
}
433433

434+
function isDarkThemeActive() {
435+
return !!(
436+
document.documentElement &&
437+
document.documentElement.getAttribute('data-theme') === 'dark'
438+
);
439+
}
440+
434441
function applyBackgroundOpacity(display, opacity) {
435442
if (!display) return;
436443
var alpha = clampOpacity(opacity) / 100;
444+
if (isDarkThemeActive()) {
445+
display.style.background = 'linear-gradient(135deg, rgba(18,29,45,' + alpha + ') 0%, rgba(22,45,68,' + Math.max(0, alpha - 0.04) + ') 52%, rgba(30,74,108,' + alpha + ') 100%)';
446+
return;
447+
}
437448
display.style.background = 'linear-gradient(135deg, rgba(68,183,254,' + alpha + ') 0%, rgba(68,183,254,' + Math.max(0, alpha - 0.05) + ') 50%, rgba(100,200,255,' + alpha + ') 100%)';
438449
}
439450

@@ -869,6 +880,38 @@
869880
applyState(state, { changedKeys: [], source: 'init' });
870881
cleanupFns.push(subscribeSettings(applyState, { immediate: false }));
871882

883+
var observedThemeDark = isDarkThemeActive();
884+
var applyThemeStateIfChanged = function(source) {
885+
var nextThemeDark = isDarkThemeActive();
886+
if (nextThemeDark === observedThemeDark) return;
887+
observedThemeDark = nextThemeDark;
888+
applyState(getSettings(), { changedKeys: ['theme'], source: source });
889+
};
890+
var onThemeChanged = function() {
891+
applyThemeStateIfChanged('subtitle-ui-theme-event');
892+
};
893+
window.addEventListener('neko-theme-changed', onThemeChanged);
894+
cleanupFns.push(function() {
895+
window.removeEventListener('neko-theme-changed', onThemeChanged);
896+
});
897+
if (window.MutationObserver && document.documentElement) {
898+
var themeObserver = new MutationObserver(function(mutations) {
899+
for (var i = 0; i < mutations.length; i += 1) {
900+
if (mutations[i].attributeName === 'data-theme') {
901+
applyThemeStateIfChanged('subtitle-ui-theme-attribute');
902+
break;
903+
}
904+
}
905+
});
906+
themeObserver.observe(document.documentElement, {
907+
attributes: true,
908+
attributeFilter: ['data-theme']
909+
});
910+
cleanupFns.push(function() {
911+
themeObserver.disconnect();
912+
});
913+
}
914+
872915
if (window.i18next && typeof window.i18next.on === 'function') {
873916
var onLanguageChanged = function(nextLocale) {
874917
updateSettings({ uiLocale: nextLocale }, {

templates/subtitle.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>N.E.K.O. Subtitle</title>
7+
<script src="/static/theme-manager.js"></script>
78
<link rel="stylesheet" href="/static/css/subtitle.css">
89
<link rel="stylesheet" href="/static/css/dark-mode.css">
910
</head>

tests/frontend/test_subtitle_incremental.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,93 @@ def _open_subtitle_harness(
2727
mock_page.goto(f"http://neko.test{path}")
2828

2929

30+
@pytest.mark.frontend
31+
def test_subtitle_background_opacity_tracks_dark_theme(
32+
mock_page: Page,
33+
):
34+
_open_subtitle_harness(
35+
mock_page,
36+
"subtitle-window-host",
37+
"""
38+
<div id="subtitle-display">
39+
<div id="subtitle-scroll"><span id="subtitle-text"></span></div>
40+
</div>
41+
""",
42+
)
43+
mock_page.evaluate(
44+
"""
45+
() => {
46+
document.documentElement.setAttribute('data-theme', 'dark');
47+
window.localStorage.setItem('subtitleOpacity', '80');
48+
}
49+
"""
50+
)
51+
mock_page.add_script_tag(path=str(PROJECT_ROOT / "static/subtitle-shared.js"))
52+
53+
result = mock_page.evaluate(
54+
"""
55+
async () => {
56+
const controller = window.nekoSubtitleShared.initSubtitleUI({ host: 'web' });
57+
const display = document.getElementById('subtitle-display');
58+
const darkBackground = display.style.background;
59+
document.documentElement.removeAttribute('data-theme');
60+
await new Promise((resolve) => setTimeout(resolve, 0));
61+
const lightBackground = display.style.background;
62+
document.documentElement.setAttribute('data-theme', 'dark');
63+
await new Promise((resolve) => setTimeout(resolve, 0));
64+
const darkBackgroundAfterAttributeChange = display.style.background;
65+
controller.destroy();
66+
return { darkBackground, lightBackground, darkBackgroundAfterAttributeChange };
67+
}
68+
"""
69+
)
70+
71+
assert "rgba(18, 29, 45, 0.8)" in result["darkBackground"]
72+
assert "rgba(68, 183, 254, 0.8)" in result["lightBackground"]
73+
assert "rgba(18, 29, 45, 0.8)" in result["darkBackgroundAfterAttributeChange"]
74+
75+
76+
@pytest.mark.frontend
77+
def test_standalone_subtitle_background_uses_stored_dark_theme_on_open(
78+
mock_page: Page,
79+
):
80+
_open_subtitle_harness(
81+
mock_page,
82+
"subtitle-window-host",
83+
"""
84+
<div id="subtitle-display">
85+
<div id="subtitle-scroll"><span id="subtitle-text"></span></div>
86+
</div>
87+
""",
88+
)
89+
mock_page.evaluate(
90+
"""
91+
() => {
92+
window.localStorage.setItem('neko-dark-mode', 'true');
93+
window.localStorage.setItem('subtitleOpacity', '80');
94+
}
95+
"""
96+
)
97+
mock_page.add_script_tag(path=str(PROJECT_ROOT / "static/theme-manager.js"))
98+
mock_page.add_script_tag(path=str(PROJECT_ROOT / "static/subtitle-shared.js"))
99+
100+
result = mock_page.evaluate(
101+
"""
102+
() => {
103+
const controller = window.nekoSubtitleShared.initSubtitleUI({ host: 'window' });
104+
const display = document.getElementById('subtitle-display');
105+
const background = display.style.background;
106+
const theme = document.documentElement.getAttribute('data-theme');
107+
controller.destroy();
108+
return { background, theme };
109+
}
110+
"""
111+
)
112+
113+
assert result["theme"] == "dark"
114+
assert "rgba(18, 29, 45, 0.8)" in result["background"]
115+
116+
30117
@pytest.mark.frontend
31118
def test_subtitle_incremental_translation_starts_when_sentence_punctuation_arrives(
32119
mock_page: Page,

tests/unit/test_react_chat_window_static.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
APP_CHAT_EXPORT_PATH = Path(__file__).resolve().parents[2] / "static" / "app-chat-export.js"
77
MUSIC_UI_PATH = Path(__file__).resolve().parents[2] / "static" / "music_ui.js"
88
STATIC_INDEX_CSS_PATH = Path(__file__).resolve().parents[2] / "static" / "css" / "index.css"
9+
STATIC_DARK_MODE_CSS_PATH = Path(__file__).resolve().parents[2] / "static" / "css" / "dark-mode.css"
910
REACT_CHAT_STYLES_PATH = Path(__file__).resolve().parents[2] / "frontend" / "react-neko-chat" / "src" / "styles.css"
1011
REACT_CHAT_APP_PATH = Path(__file__).resolve().parents[2] / "frontend" / "react-neko-chat" / "src" / "App.tsx"
1112
CHAT_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "chat.html"
13+
SUBTITLE_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "subtitle.html"
1214
COMPACT_EXPORT_HISTORY_PANEL_PATH = (
1315
Path(__file__).resolve().parents[2] / "frontend" / "react-neko-chat" / "src" / "CompactExportHistoryPanel.tsx"
1416
)
@@ -39,6 +41,26 @@ def assert_no_layout_transition(block: str) -> None:
3941
assert prop not in transition_section
4042

4143

44+
def test_subtitle_window_dark_mode_keeps_transparent_background():
45+
source = STATIC_DARK_MODE_CSS_PATH.read_text(encoding="utf-8")
46+
selector = (
47+
'html[data-theme="dark"].subtitle-window-host,\n'
48+
'html[data-theme="dark"].subtitle-window-host body.subtitle-window-host {'
49+
)
50+
assert selector in source
51+
block = source.split(selector, 1)[1].split("}", 1)[0]
52+
53+
assert "background: transparent !important;" in block
54+
assert source.index(selector) > source.index("background: #000 !important;")
55+
56+
57+
def test_standalone_subtitle_page_initializes_theme_before_subtitle_scripts():
58+
source = SUBTITLE_TEMPLATE_PATH.read_text(encoding="utf-8")
59+
60+
assert '<script src="/static/theme-manager.js"></script>' in source
61+
assert source.index('/static/theme-manager.js') < source.index('/static/subtitle-shared.js')
62+
63+
4264
def test_chat_surface_mode_preference_is_shared_with_electron():
4365
source = APP_REACT_CHAT_WINDOW_PATH.read_text(encoding="utf-8")
4466

0 commit comments

Comments
 (0)