mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 20:12:59 -07:00
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:
@@ -56,6 +56,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||
assert.equal(config.subtitleStyle.primaryDefaultMode, 'visible');
|
||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'visible',
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
|
||||
@@ -5,6 +5,14 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
return [
|
||||
{
|
||||
path: 'subtitleStyle.primaryDefaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['hidden', 'visible', 'hover'],
|
||||
defaultValue: defaultConfig.subtitleStyle.primaryDefaultMode,
|
||||
description:
|
||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -147,6 +147,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
|
||||
if (isObject(src.subtitleStyle)) {
|
||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||
const fallbackSubtitleStylePrimaryDefaultMode = resolved.subtitleStyle.primaryDefaultMode;
|
||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||
@@ -190,6 +191,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const primaryDefaultMode = (src.subtitleStyle as { primaryDefaultMode?: unknown })
|
||||
.primaryDefaultMode;
|
||||
if (
|
||||
primaryDefaultMode === 'hidden' ||
|
||||
primaryDefaultMode === 'visible' ||
|
||||
primaryDefaultMode === 'hover'
|
||||
) {
|
||||
resolved.subtitleStyle.primaryDefaultMode = primaryDefaultMode;
|
||||
} else if (primaryDefaultMode !== undefined) {
|
||||
resolved.subtitleStyle.primaryDefaultMode = fallbackSubtitleStylePrimaryDefaultMode;
|
||||
warn(
|
||||
'subtitleStyle.primaryDefaultMode',
|
||||
primaryDefaultMode,
|
||||
resolved.subtitleStyle.primaryDefaultMode,
|
||||
'Expected hidden, visible, or hover.',
|
||||
);
|
||||
}
|
||||
|
||||
const preserveLineBreaks = asBoolean(
|
||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||
);
|
||||
|
||||
@@ -66,6 +66,31 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'hover',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.primaryDefaultMode, 'hover');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'auto' as never,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.primaryDefaultMode, 'visible');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.primaryDefaultMode' &&
|
||||
warning.message === 'Expected hidden, visible, or hover.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import {
|
||||
buildConfigHotReloadPayload,
|
||||
buildRestartRequiredConfigMessage,
|
||||
createConfigHotReloadAppliedHandler,
|
||||
createConfigHotReloadMessageHandler,
|
||||
@@ -56,6 +57,17 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.subtitleStyle.primaryDefaultMode = 'hover';
|
||||
config.secondarySub.defaultMode = 'hidden';
|
||||
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
|
||||
assert.equal(payload.primarySubMode, 'hover');
|
||||
assert.equal(payload.secondarySubMode, 'hidden');
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -54,6 +54,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
sessionBindingWarnings,
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
subtitleSidebar: config.subtitleSidebar,
|
||||
primarySubMode: config.subtitleStyle.primaryDefaultMode,
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
YoutubePickerResolveResult,
|
||||
} from './integrations';
|
||||
import type {
|
||||
PrimarySubMode,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
@@ -331,6 +332,7 @@ export interface ConfigHotReloadPayload {
|
||||
sessionBindingWarnings: SessionBindingWarning[];
|
||||
subtitleStyle: SubtitleStyleConfig | null;
|
||||
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||
primarySubMode: PrimarySubMode;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ export interface SubtitleStyle {
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
export type SecondarySubMode = 'hidden' | 'visible' | 'hover';
|
||||
export type SubtitleBarMode = 'hidden' | 'visible' | 'hover';
|
||||
export type PrimarySubMode = SubtitleBarMode;
|
||||
export type SecondarySubMode = SubtitleBarMode;
|
||||
|
||||
export interface SecondarySubConfig {
|
||||
secondarySubLanguages?: string[];
|
||||
@@ -67,6 +69,7 @@ export type NPlusOneMatchMode = 'headword' | 'surface';
|
||||
export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
primaryDefaultMode?: PrimarySubMode;
|
||||
enableJlpt?: boolean;
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user