feat(overlay): add primary subtitle bar visibility modes (#63)

- Cycle `v` through `hidden | visible | hover` instead of a boolean toggle
- Add `subtitleStyle.primaryDefaultMode` config with default `visible`
- Carry primary mode independently from secondary in hot-reload payload
- Add hover CSS: transparent until hovered, then fully visible
- Show primary-specific OSD text on each mode change
This commit is contained in:
2026-05-12 23:00:32 -07:00
committed by GitHub
parent 430373f010
commit e5c1135501
20 changed files with 221 additions and 13 deletions
+17 -1
View File
@@ -407,21 +407,37 @@ function createKeyboardHandlerHarness() {
};
}
test('primary subtitle visibility key hides and restores the subtitle bar without mpv sub-visibility', async () => {
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hover'), true);
assert.equal(ctx.state.primarySubtitleMode, 'hover');
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true);
assert.equal(ctx.state.primarySubtitleMode, 'hidden');
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hover'), false);
assert.equal(ctx.state.primarySubtitleMode, 'visible');
assert.equal(
testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')),
false,
);
assert.deepEqual(
testGlobals.mpvCommands.filter((command) => command[0] === 'show-text'),
[
['show-text', 'Primary subtitle: hover', '1500'],
['show-text', 'Primary subtitle: hidden', '1500'],
['show-text', 'Primary subtitle: visible', '1500'],
],
);
} finally {
testGlobals.restore();
}
+12 -8
View File
@@ -1,4 +1,4 @@
import type { CompiledSessionBinding, ShortcutsConfig } from '../../types';
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
@@ -370,13 +370,17 @@ export function createKeyboardHandlers(
}
function togglePrimarySubtitleBarVisibility(): void {
const visible = !ctx.state.primarySubtitleBarVisible;
ctx.state.primarySubtitleBarVisible = visible;
if (visible) {
ctx.dom.subtitleContainer.classList.remove('primary-sub-hidden');
} else {
ctx.dom.subtitleContainer.classList.add('primary-sub-hidden');
}
const modes: PrimarySubMode[] = ['hidden', 'visible', 'hover'];
const currentIndex = modes.indexOf(ctx.state.primarySubtitleMode);
const nextMode = modes[((currentIndex >= 0 ? currentIndex : 1) + 1) % modes.length]!;
ctx.state.primarySubtitleMode = nextMode;
ctx.dom.subtitleContainer.classList.remove(
'primary-sub-hidden',
'primary-sub-visible',
'primary-sub-hover',
);
ctx.dom.subtitleContainer.classList.add(`primary-sub-${nextMode}`);
window.electronAPI.sendMpvCommand(['show-text', `Primary subtitle: ${nextMode}`, '1500']);
}
async function handleMarkWatched(): Promise<void> {
+2
View File
@@ -672,6 +672,7 @@ async function init(): Promise<void> {
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
@@ -694,6 +695,7 @@ async function init(): Promise<void> {
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
+3 -2
View File
@@ -12,6 +12,7 @@ import type {
RuntimeOptionState,
RuntimeOptionValue,
CharacterDictionarySelectionSnapshot,
PrimarySubMode,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleCue,
@@ -134,7 +135,7 @@ export type RendererState = {
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
primarySubtitleBarVisible: boolean;
primarySubtitleMode: PrimarySubMode;
};
export function createRendererState(): RendererState {
@@ -245,6 +246,6 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
primarySubtitleBarVisible: true,
primarySubtitleMode: 'visible',
};
}
+10
View File
@@ -684,6 +684,16 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
display: none;
}
#subtitleContainer.primary-sub-hover {
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: auto;
}
#subtitleContainer.primary-sub-hover:hover {
opacity: 1;
}
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
+10
View File
@@ -1188,6 +1188,16 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/);
const primaryHoverBlock = extractClassBlock(cssText, '#subtitleContainer.primary-sub-hover');
assert.match(primaryHoverBlock, /opacity:\s*0;/);
assert.match(primaryHoverBlock, /pointer-events:\s*auto;/);
const primaryHoverVisibleBlock = extractClassBlock(
cssText,
'#subtitleContainer.primary-sub-hover:hover',
);
assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/);
const secondaryEmbeddedHoverBlock = extractClassBlock(
cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
+18 -1
View File
@@ -1,4 +1,10 @@
import type { MergedToken, SecondarySubMode, SubtitleData, SubtitleStyleConfig } from '../types';
import type {
MergedToken,
PrimarySubMode,
SecondarySubMode,
SubtitleData,
SubtitleStyleConfig,
} from '../types';
import type { RendererContext } from './context';
type FrequencyRenderSettings = {
@@ -613,6 +619,16 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
}
function updatePrimarySubMode(mode: PrimarySubMode): void {
ctx.state.primarySubtitleMode = mode;
ctx.dom.subtitleContainer.classList.remove(
'primary-sub-hidden',
'primary-sub-visible',
'primary-sub-hover',
);
ctx.dom.subtitleContainer.classList.add(`primary-sub-${mode}`);
}
function applySubtitleFontSize(fontSize: number): void {
const clampedSize = Math.max(10, fontSize);
ctx.dom.subtitleRoot.style.fontSize = `${clampedSize}px`;
@@ -791,6 +807,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
applySubtitleStyle,
renderSecondarySub,
renderSubtitle,
updatePrimarySubMode,
updateSecondarySubMode,
};
}