Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 21:41:46 -08:00
1 parent 6d0f37ac21
commit b99409bbcd
60 files changed
+1900 -219

No files matched your search

+28
View File
@@ -2216,6 +2216,34 @@ test('parses controller face button labels and warns on unknown styles', () => {
);
});
test('controller profiles inherit the button style unless they set their own', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
JSON.stringify({
controller: {
faceButtonLabels: 'nintendo',
profiles: {
'pad-ps': { faceButtonLabels: 'playstation' },
'pad-plain': { label: 'Plain' },
'pad-bad': { faceButtonLabels: 'sega' },
},
},
}),
'utf-8',
);
const service = new ConfigService(dir);
const { profiles } = service.getConfig().controller;
assert.equal(profiles['pad-ps']!.faceButtonLabels, 'playstation');
assert.equal(profiles['pad-plain']!.faceButtonLabels, 'nintendo');
assert.equal(profiles['pad-bad']!.faceButtonLabels, 'nintendo');
assert.deepEqual(
service.getWarnings().map((warning) => warning.path),
['controller.profiles.pad-bad.faceButtonLabels'],
);
});
test('parses controller review bindings globally and per profile, and warns on bad entries', () => {
const dir = makeTempDir();
fs.writeFileSync(
+4
View File
@@ -85,6 +85,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
autoPauseVideoOnKeyboardMode: false,
primaryVisibleOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
+28 -3
View File
@@ -61,7 +61,8 @@ export function buildCoreConfigOptionRegistry(
{
id: 'toggleMpvPause',
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding descriptor for toggling mpv play/pause.',
description:
'Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode.',
},
{
id: 'holdSecondarySub',
@@ -81,6 +82,30 @@ export function buildCoreConfigOptionRegistry(
description:
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
{
id: 'previousSubtitle',
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
description:
'Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode.',
},
{
id: 'nextSubtitle',
defaultValue: defaultConfig.controller.bindings.nextSubtitle,
description:
'Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode.',
},
{
id: 'replaySubtitle',
defaultValue: defaultConfig.controller.bindings.replaySubtitle,
description:
'Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode.',
},
{
id: 'playNextSubtitle',
defaultValue: defaultConfig.controller.bindings.playNextSubtitle,
description:
'Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode.',
},
] as const;
const axisBindings = [
@@ -235,10 +260,10 @@ export function buildCoreConfigOptionRegistry(
{
path: 'controller.faceButtonLabels',
kind: 'enum',
enumValues: ['xbox', 'nintendo'],
enumValues: ['xbox', 'nintendo', 'playstation'],
defaultValue: defaultConfig.controller.faceButtonLabels,
description:
"Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C.",
'Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others.',
},
{
path: 'controller.triggerDeadzone',
@@ -100,6 +100,13 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
},
{
path: 'subtitleStyle.autoPauseVideoOnKeyboardMode',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnKeyboardMode,
description:
'Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is.',
},
{
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
kind: 'boolean',
+24 -5
View File
@@ -9,6 +9,7 @@ import type {
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import {
isControllerFaceButtonLabels,
isControllerScreenAction,
parseControllerReviewBinding,
type ControllerScreenBindings,
@@ -18,6 +19,8 @@ import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'.";
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
@@ -98,6 +101,10 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
'holdSecondarySub',
'previousEntry',
'nextEntry',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
@@ -360,15 +367,15 @@ export function applyControllerConfig(context: ResolveContext): void {
);
}
const faceButtonLabels = asString(src.controller.faceButtonLabels);
if (faceButtonLabels === 'xbox' || faceButtonLabels === 'nintendo') {
const faceButtonLabels = src.controller.faceButtonLabels;
if (isControllerFaceButtonLabels(faceButtonLabels)) {
resolved.controller.faceButtonLabels = faceButtonLabels;
} else if (src.controller.faceButtonLabels !== undefined) {
} else if (faceButtonLabels !== undefined) {
warn(
'controller.faceButtonLabels',
src.controller.faceButtonLabels,
faceButtonLabels,
resolved.controller.faceButtonLabels,
"Expected 'xbox' or 'nintendo'.",
FACE_BUTTON_LABELS_EXPECTED,
);
}
@@ -465,9 +472,21 @@ export function applyControllerConfig(context: ResolveContext): void {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
faceButtonLabels: resolved.controller.faceButtonLabels,
reviewBindings: structuredClone(resolved.controller.reviewBindings),
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
};
// Set per controller from Alt+C; falls back to controller.faceButtonLabels.
if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) {
profile.faceButtonLabels = rawProfile.faceButtonLabels;
} else if (rawProfile.faceButtonLabels !== undefined) {
warn(
`controller.profiles.${profileId}.faceButtonLabels`,
rawProfile.faceButtonLabels,
profile.faceButtonLabels,
FACE_BUTTON_LABELS_EXPECTED,
);
}
applyControllerButtonIndices(
rawProfile.buttonIndices,
profile.buttonIndices,
+19
View File
@@ -125,6 +125,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
const fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode =
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode;
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
@@ -274,6 +276,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const rawAutoPauseVideoOnKeyboardMode = (
src.subtitleStyle as { autoPauseVideoOnKeyboardMode?: unknown }
).autoPauseVideoOnKeyboardMode;
const autoPauseVideoOnKeyboardMode = asBoolean(rawAutoPauseVideoOnKeyboardMode);
if (autoPauseVideoOnKeyboardMode !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode = autoPauseVideoOnKeyboardMode;
} else if (rawAutoPauseVideoOnKeyboardMode !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode =
fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode;
warn(
'subtitleStyle.autoPauseVideoOnKeyboardMode',
rawAutoPauseVideoOnKeyboardMode,
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode,
'Expected boolean.',
);
}
const primaryVisibleOnYomitanPopup = asBoolean(
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
.primaryVisibleOnYomitanPopup,
+25
View File
@@ -346,3 +346,28 @@ test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns
),
);
});
test('subtitleStyle autoPauseVideoOnKeyboardMode is off by default and falls back on invalid value', () => {
const defaults = createResolveContext({});
applySubtitleDomainConfig(defaults.context);
assert.equal(defaults.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
const enabled = createResolveContext({ subtitleStyle: { autoPauseVideoOnKeyboardMode: true } });
applySubtitleDomainConfig(enabled.context);
assert.equal(enabled.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, true);
const { context, warnings } = createResolveContext({
subtitleStyle: {
autoPauseVideoOnKeyboardMode: 'yes' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' &&
warning.message === 'Expected boolean.',
),
);
});
+2
View File
@@ -233,6 +233,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
'subtitleStyle.autoPauseVideoOnKeyboardMode': 'Pause Video In Keyboard-Only Mode',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
@@ -344,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Behavior' };