mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-31 06:12:12 -07:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
36
src/renderer/positioning/controller.ts
Normal file
36
src/renderer/positioning/controller.ts
Normal 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;
|
||||
}
|
||||
187
src/renderer/positioning/invisible-layout-helpers.ts
Normal file
187
src/renderer/positioning/invisible-layout-helpers.ts
Normal 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);
|
||||
}
|
||||
133
src/renderer/positioning/invisible-layout-metrics.ts
Normal file
133
src/renderer/positioning/invisible-layout-metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
src/renderer/positioning/invisible-layout.ts
Normal file
85
src/renderer/positioning/invisible-layout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
161
src/renderer/positioning/invisible-offset.ts
Normal file
161
src/renderer/positioning/invisible-offset.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
src/renderer/positioning/position-state.ts
Normal file
120
src/renderer/positioning/position-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user