feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import type { ModalStateReader, RendererContext } from '../context';
import {
createInMemorySubtitlePositionController,
type SubtitlePositionController,
} from './position-state.js';
import {
createInvisibleOffsetController,
type InvisibleOffsetController,
} from './invisible-offset.js';
import {
createMpvSubtitleLayoutController,
type MpvSubtitleLayoutController,
} from './invisible-layout.js';
type PositioningControllerOptions = {
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
applySubtitleFontSize: (fontSize: number) => void;
};
export function createPositioningController(
ctx: RendererContext,
options: PositioningControllerOptions,
) {
const visible = createInMemorySubtitlePositionController(ctx);
const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader);
const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, {
applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
});
return {
...visible,
...invisibleOffset,
...invisibleLayout,
} as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController;
}

View File

@@ -0,0 +1,187 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '0.92';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.2';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.3';
export function applyContainerBaseLayout(
ctx: RendererContext,
params: {
horizontalAvailable: number;
leftInset: number;
marginX: number;
hAlign: 0 | 1 | 2;
},
): void {
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
ctx.dom.subtitleContainer.style.position = 'absolute';
ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.width = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.padding = '0';
ctx.dom.subtitleContainer.style.background = 'transparent';
ctx.dom.subtitleContainer.style.marginBottom = '0';
ctx.dom.subtitleContainer.style.pointerEvents = 'none';
ctx.dom.subtitleContainer.style.left = `${leftInset + marginX}px`;
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
ctx.dom.subtitleContainer.style.textAlign = '';
if (hAlign === 0) {
ctx.dom.subtitleContainer.style.textAlign = 'left';
ctx.dom.subtitleRoot.style.textAlign = 'left';
} else if (hAlign === 2) {
ctx.dom.subtitleContainer.style.textAlign = 'right';
ctx.dom.subtitleRoot.style.textAlign = 'right';
} else {
ctx.dom.subtitleContainer.style.textAlign = 'center';
ctx.dom.subtitleRoot.style.textAlign = 'center';
}
ctx.dom.subtitleRoot.style.display = 'inline-block';
ctx.dom.subtitleRoot.style.maxWidth = '100%';
ctx.dom.subtitleRoot.style.pointerEvents = 'auto';
}
export function applyVerticalPosition(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
renderAreaHeight: number;
topInset: number;
bottomInset: number;
marginY: number;
effectiveFontSize: number;
borderPx: number;
shadowPx: number;
vAlign: 0 | 1 | 2;
},
): void {
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset);
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5);
if (params.vAlign === 2) {
ctx.dom.subtitleContainer.style.top = `${Math.max(
0,
params.topInset + params.marginY - baselineCompensationPx,
)}px`;
ctx.dom.subtitleContainer.style.bottom = '';
return;
}
if (params.vAlign === 1) {
ctx.dom.subtitleContainer.style.top = '50%';
ctx.dom.subtitleContainer.style.bottom = '';
ctx.dom.subtitleContainer.style.transform = 'translateY(-50%)';
return;
}
const anchorY =
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx;
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY);
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
}
function resolveFontFamily(rawFont: string): string {
const strippedFont = rawFont
.replace(
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
'',
)
.trim();
return strippedFont !== rawFont
? `"${rawFont}", "${strippedFont}", sans-serif`
: `"${rawFont}", sans-serif`;
}
function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
if (!isMacOSPlatform) return 'normal';
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
}
function resolveLetterSpacing(
spacing: number,
pxPerScaledPixel: number,
isMacOSPlatform: boolean,
): string {
if (Math.abs(spacing) > 0.0001) {
return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`;
}
return isMacOSPlatform ? '-0.02em' : '0px';
}
function applyComputedLineHeightCompensation(
ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
return;
}
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
if (halfLeading <= 0.5) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (Number.isFinite(currentBottom)) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
}
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
if (Number.isFinite(currentTop)) {
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
}
}
function applyMacOSAdjustments(ctx: RendererContext): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
if (!isMacOSPlatform) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (!Number.isFinite(currentBottom)) return;
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0,
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
)}px`;
}
export function applyTypography(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
pxPerScaledPixel: number;
effectiveFontSize: number;
},
): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
ctx.dom.subtitleRoot.style.setProperty(
'line-height',
resolveLineHeight(lineCount, isMacOSPlatform),
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty(
'letter-spacing',
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform),
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none';
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400';
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
ctx.dom.subtitleRoot.style.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = '';
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);
}

View File

@@ -0,0 +1,133 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
export type SubtitleAlignment = { hAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2 };
export type SubtitleLayoutGeometry = {
renderAreaHeight: number;
renderAreaWidth: number;
leftInset: number;
rightInset: number;
topInset: number;
bottomInset: number;
horizontalAvailable: number;
marginY: number;
marginX: number;
pxPerScaledPixel: number;
effectiveFontSize: number;
};
export function calculateOsdScale(
metrics: MpvSubtitleRenderMetrics,
isMacOSPlatform: boolean,
viewportWidth: number,
viewportHeight: number,
devicePixelRatio: number,
): number {
const dims = metrics.osdDimensions;
if (!isMacOSPlatform || !dims) {
return devicePixelRatio;
}
const ratios = [dims.w / Math.max(1, viewportWidth), dims.h / Math.max(1, viewportHeight)].filter(
(value) => Number.isFinite(value) && value > 0,
);
const avgRatio =
ratios.length > 0
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
: devicePixelRatio;
return avgRatio > 1.25 ? avgRatio : 1;
}
export function calculateSubtitlePosition(
_metrics: MpvSubtitleRenderMetrics,
_scale: number,
alignment: number,
): SubtitleAlignment {
return {
hAlign: ((alignment - 1) % 3) as 0 | 1 | 2,
vAlign: Math.floor((alignment - 1) / 3) as 0 | 1 | 2,
};
}
function resolveLinePadding(
metrics: MpvSubtitleRenderMetrics,
pxPerScaledPixel: number,
): { marginY: number; marginX: number } {
return {
marginY: metrics.subMarginY * pxPerScaledPixel,
marginX: Math.max(0, metrics.subMarginX * pxPerScaledPixel),
};
}
export function applyPlatformFontCompensation(
fontSizePx: number,
isMacOSPlatform: boolean,
): number {
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx;
}
function calculateGeometry(
metrics: MpvSubtitleRenderMetrics,
osdToCssScale: number,
): Omit<SubtitleLayoutGeometry, 'marginY' | 'marginX' | 'pxPerScaledPixel' | 'effectiveFontSize'> {
const dims = metrics.osdDimensions;
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
const videoLeftInset = dims ? dims.ml / osdToCssScale : 0;
const videoRightInset = dims ? dims.mr / osdToCssScale : 0;
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
const anchorToVideoArea = !metrics.subUseMargins;
const leftInset = anchorToVideoArea ? videoLeftInset : 0;
const rightInset = anchorToVideoArea ? videoRightInset : 0;
const topInset = anchorToVideoArea ? videoTopInset : 0;
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
return {
renderAreaHeight,
renderAreaWidth,
leftInset,
rightInset,
topInset,
bottomInset,
horizontalAvailable,
};
}
export function calculateSubtitleMetrics(
ctx: RendererContext,
metrics: MpvSubtitleRenderMetrics,
): SubtitleLayoutGeometry {
const osdToCssScale = calculateOsdScale(
metrics,
ctx.platform.isMacOSPlatform,
window.innerWidth,
window.innerHeight,
window.devicePixelRatio || 1,
);
const geometry = calculateGeometry(metrics, osdToCssScale);
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
const computedFontSize =
metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
const effectiveFontSize = applyPlatformFontCompensation(
computedFontSize,
ctx.platform.isMacOSPlatform,
);
const spacing = resolveLinePadding(metrics, pxPerScaledPixel);
return {
...geometry,
marginY: spacing.marginY,
marginX: spacing.marginX,
pxPerScaledPixel,
effectiveFontSize,
};
}

View File

@@ -0,0 +1,85 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
import {
applyContainerBaseLayout,
applyTypography,
applyVerticalPosition,
} from './invisible-layout-helpers.js';
import { calculateSubtitleMetrics, calculateSubtitlePosition } from './invisible-layout-metrics.js';
export type MpvSubtitleLayoutController = {
applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: MpvSubtitleRenderMetrics,
source: string,
) => void;
};
export function createMpvSubtitleLayoutController(
ctx: RendererContext,
applySubtitleFontSize: (fontSize: number) => void,
options: {
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
},
): MpvSubtitleLayoutController {
function applyInvisibleSubtitleLayoutFromMpvMetrics(
metrics: MpvSubtitleRenderMetrics,
source: string,
): void {
ctx.state.mpvSubtitleRenderMetrics = metrics;
const geometry = calculateSubtitleMetrics(ctx, metrics);
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2);
applySubtitleFontSize(geometry.effectiveFontSize);
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
const effectiveShadowOffset = metrics.subShadowOffset * geometry.pxPerScaledPixel;
document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`);
applyContainerBaseLayout(ctx, {
horizontalAvailable: Math.max(
0,
geometry.horizontalAvailable - Math.round(geometry.marginX * 2),
),
leftInset: geometry.leftInset,
marginX: geometry.marginX,
hAlign: alignment.hAlign,
});
applyVerticalPosition(ctx, {
metrics,
renderAreaHeight: geometry.renderAreaHeight,
topInset: geometry.topInset,
bottomInset: geometry.bottomInset,
marginY: geometry.marginY,
effectiveFontSize: geometry.effectiveFontSize,
borderPx: effectiveBorderSize,
shadowPx: effectiveShadowOffset,
vAlign: alignment.vAlign,
});
applyTypography(ctx, {
metrics,
pxPerScaledPixel: geometry.pxPerScaledPixel,
effectiveFontSize: geometry.effectiveFontSize,
});
ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom) ? parsedBottom : null;
const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top);
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null;
options.applyInvisibleSubtitleOffsetPosition();
options.updateInvisiblePositionEditHud();
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
}
return {
applyInvisibleSubtitleLayoutFromMpvMetrics,
};
}

View File

@@ -0,0 +1,161 @@
import type { SubtitlePosition } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export type InvisibleOffsetController = {
applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void;
saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void;
setupInvisiblePositionEditHud: () => void;
};
function formatEditHudText(offsetX: number, offsetY: number): string {
return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`;
}
function createEditPositionText(ctx: RendererContext): string {
return formatEditHudText(
ctx.state.invisibleSubtitleOffsetXPx,
ctx.state.invisibleSubtitleOffsetYPx,
);
}
function applyOffsetByBasePosition(ctx: RendererContext): void {
const nextLeft = ctx.state.invisibleLayoutBaseLeftPx + ctx.state.invisibleSubtitleOffsetXPx;
ctx.dom.subtitleContainer.style.left = `${nextLeft}px`;
if (ctx.state.invisibleLayoutBaseBottomPx !== null) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0,
ctx.state.invisibleLayoutBaseBottomPx + ctx.state.invisibleSubtitleOffsetYPx,
)}px`;
ctx.dom.subtitleContainer.style.top = '';
return;
}
if (ctx.state.invisibleLayoutBaseTopPx !== null) {
ctx.dom.subtitleContainer.style.top = `${Math.max(
0,
ctx.state.invisibleLayoutBaseTopPx - ctx.state.invisibleSubtitleOffsetYPx,
)}px`;
ctx.dom.subtitleContainer.style.bottom = '';
}
}
export function createInvisibleOffsetController(
ctx: RendererContext,
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>,
): InvisibleOffsetController {
function setInvisiblePositionEditMode(enabled: boolean): void {
if (!ctx.platform.isInvisibleLayer) return;
if (ctx.state.invisiblePositionEditMode === enabled) return;
ctx.state.invisiblePositionEditMode = enabled;
document.body.classList.toggle('invisible-position-edit', enabled);
if (enabled) {
ctx.state.invisiblePositionEditStartX = ctx.state.invisibleSubtitleOffsetXPx;
ctx.state.invisiblePositionEditStartY = ctx.state.invisibleSubtitleOffsetYPx;
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
} else {
if (!ctx.state.isOverSubtitle && !modalStateReader.isAnySettingsModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
}
updateInvisiblePositionEditHud();
}
function updateInvisiblePositionEditHud(): void {
if (!ctx.state.invisiblePositionEditHud) return;
ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx);
}
function applyInvisibleSubtitleOffsetPosition(): void {
applyOffsetByBasePosition(ctx);
}
function applyInvisibleStoredSubtitlePosition(
position: SubtitlePosition | null,
source: string,
): void {
if (position && typeof position.yPercent === 'number' && Number.isFinite(position.yPercent)) {
ctx.state.persistedSubtitlePosition = {
...ctx.state.persistedSubtitlePosition,
yPercent: position.yPercent,
};
}
if (position) {
const nextX =
typeof position.invisibleOffsetXPx === 'number' &&
Number.isFinite(position.invisibleOffsetXPx)
? position.invisibleOffsetXPx
: 0;
const nextY =
typeof position.invisibleOffsetYPx === 'number' &&
Number.isFinite(position.invisibleOffsetYPx)
? position.invisibleOffsetYPx
: 0;
ctx.state.invisibleSubtitleOffsetXPx = nextX;
ctx.state.invisibleSubtitleOffsetYPx = nextY;
} else {
ctx.state.invisibleSubtitleOffsetXPx = 0;
ctx.state.invisibleSubtitleOffsetYPx = 0;
}
applyOffsetByBasePosition(ctx);
console.log(
'[invisible-overlay] Applied subtitle offset from',
source,
`${ctx.state.invisibleSubtitleOffsetXPx}px`,
`${ctx.state.invisibleSubtitleOffsetYPx}px`,
);
updateInvisiblePositionEditHud();
}
function saveInvisiblePositionEdit(): void {
const nextPosition = {
yPercent: ctx.state.persistedSubtitlePosition.yPercent,
invisibleOffsetXPx: ctx.state.invisibleSubtitleOffsetXPx,
invisibleOffsetYPx: ctx.state.invisibleSubtitleOffsetYPx,
};
window.electronAPI.saveSubtitlePosition(nextPosition);
setInvisiblePositionEditMode(false);
}
function cancelInvisiblePositionEdit(): void {
ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX;
ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY;
applyOffsetByBasePosition(ctx);
setInvisiblePositionEditMode(false);
}
function setupInvisiblePositionEditHud(): void {
if (!ctx.platform.isInvisibleLayer) return;
const hud = document.createElement('div');
hud.id = 'invisiblePositionEditHud';
hud.className = 'invisible-position-edit-hud';
ctx.dom.overlay.appendChild(hud);
ctx.state.invisiblePositionEditHud = hud;
updateInvisiblePositionEditHud();
}
return {
applyInvisibleStoredSubtitlePosition,
applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud,
setInvisiblePositionEditMode,
saveInvisiblePositionEdit,
cancelInvisiblePositionEdit,
setupInvisiblePositionEditHud,
};
}

View File

@@ -0,0 +1,120 @@
import type { SubtitlePosition } from '../../types';
import type { RendererContext } from '../context';
const PREFERRED_Y_PERCENT_MIN = 2;
const PREFERRED_Y_PERCENT_MAX = 80;
export type SubtitlePositionController = {
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
getCurrentYPercent: () => number;
applyYPercent: (yPercent: number) => void;
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
};
function clampYPercent(yPercent: number): number {
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
}
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
if (!position || typeof position.yPercent !== 'number' || !Number.isFinite(position.yPercent)) {
return ctx.state.persistedSubtitlePosition.yPercent;
}
return position.yPercent;
}
function getPersistedOffset(
position: SubtitlePosition | null,
key: 'invisibleOffsetXPx' | 'invisibleOffsetYPx',
): number {
if (position && typeof position[key] === 'number' && Number.isFinite(position[key])) {
return position[key];
}
return 0;
}
function updatePersistedSubtitlePosition(
ctx: RendererContext,
position: SubtitlePosition | null,
): void {
ctx.state.persistedSubtitlePosition = {
yPercent: getPersistedYPercent(ctx, position),
invisibleOffsetXPx: getPersistedOffset(position, 'invisibleOffsetXPx'),
invisibleOffsetYPx: getPersistedOffset(position, 'invisibleOffsetYPx'),
};
}
function getNextPersistedPosition(
ctx: RendererContext,
patch: Partial<SubtitlePosition>,
): SubtitlePosition {
return {
yPercent:
typeof patch.yPercent === 'number' && Number.isFinite(patch.yPercent)
? patch.yPercent
: ctx.state.persistedSubtitlePosition.yPercent,
invisibleOffsetXPx:
typeof patch.invisibleOffsetXPx === 'number' && Number.isFinite(patch.invisibleOffsetXPx)
? patch.invisibleOffsetXPx
: (ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0),
invisibleOffsetYPx:
typeof patch.invisibleOffsetYPx === 'number' && Number.isFinite(patch.invisibleOffsetYPx)
? patch.invisibleOffsetYPx
: (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0),
};
}
export function createInMemorySubtitlePositionController(
ctx: RendererContext,
): SubtitlePositionController {
function getCurrentYPercent(): number {
if (ctx.state.currentYPercent !== null) {
return ctx.state.currentYPercent;
}
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
return ctx.state.currentYPercent;
}
function applyYPercent(yPercent: number): void {
const clampedPercent = clampYPercent(yPercent);
ctx.state.currentYPercent = clampedPercent;
const marginBottom = (clampedPercent / 100) * window.innerHeight;
ctx.dom.subtitleContainer.style.position = '';
ctx.dom.subtitleContainer.style.left = '';
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
}
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
const nextPosition = getNextPersistedPosition(ctx, patch);
ctx.state.persistedSubtitlePosition = nextPosition;
window.electronAPI.saveSubtitlePosition(nextPosition);
}
function applyStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void {
updatePersistedSubtitlePosition(ctx, position);
if (position && position.yPercent !== undefined) {
applyYPercent(position.yPercent);
console.log('Applied subtitle position from', source, ':', position.yPercent, '%');
return;
}
const defaultMarginBottom = 60;
const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
applyYPercent(defaultYPercent);
console.log('Applied default subtitle position from', source);
}
return {
applyStoredSubtitlePosition,
getCurrentYPercent,
applyYPercent,
persistSubtitlePositionPatch,
};
}