Skip to content

Commit 7b749a7

Browse files
authored
feat(player/subtitle): comprehensive subtitle overlay improvements (#226)
* refactor(player): extract subtitle overlay logic into separate hooks and utilities * refactor(player): enhance subtitle styling and introduce content width hook - Updated SubtitleContent component to use fixed line-height and color values. - Modified text handling in OriginalTextLine and TranslatedTextLine for better word wrapping and alignment. - Added useContentWidth hook to manage dynamic width styling for subtitles, improving layout consistency. - Adjusted SubtitleOverlay to utilize new width styles for better responsiveness in non-mask mode. * refactor(player): improve drag bounds calculation for mask mode - Enhanced the calculateDragBounds function to ensure that subtitle components remain fully within the mask area by calculating absolute dimensions based on the mask viewport. - Introduced logic to prevent overflow of the right and bottom edges of the mask, improving the overall layout and user experience in mask mode. * refactor(subtitle-overlay): show resize handle only on hover in mask mode - Change resize handle visibility condition to require both mask mode and hover state - Reduce visual clutter by hiding resize handle when not actively interacting * fix(player/subtitle): correct token rendering and selection logic - Render non-clickable tokens as plain spans instead of WordToken components - Only apply selection/hover states to clickable tokens - Simplify WordToken styling by removing conditional clickable styles - Improve performance by reducing unnecessary component rendering * refactor(subtitle-overlay): centralize selected text state management - Move selected text state from UI hook to player store for global access - Add setSelectedText action in player store to update selected text - Remove local selectedText state from SubtitleOverlayUI hook - Enhance text selection logic with additional logging for debugging - Clear selected text on mouse leave and outside click events - Maintain backward compatibility with existing onTextSelection callback * fix(player/subtitle): resolve toast display conflicts and coordinate transform issues - Fix toast disappearing issue: add toast type differentiation to prevent mask-onboarding logic from interfering with copy success toast - Fix syntax errors: incomplete variable declaration and undefined variable references in coordinateTransform.ts - Clean up logging: remove duplicate logger.debug calls and unused variable references * fix(player/subtitle): remove unused heightLimit variable to resolve TypeScript error * fix(player/subtitle): type resize handle events for div - update `onMouseDown` and `onDoubleClick` to use `React.MouseEvent<HTMLDivElement>` - improve type safety by matching the actual element * refactor(player/subtitle): remove duplicate useSubtitleOverlay calls and redundant onTextSelection forwarding - Remove duplicate useSubtitleOverlay hook call in SubtitleOverlay.tsx - Remove unnecessary handleTextSelection callback and onTextSelection prop forwarding - Remove onTextSelection parameter from SubtitleContent interface and implementation - Simplify text selection state management by using only internal setSelectedText calls - Update dependency arrays in useCallback and useEffect hooks - Improve performance by reducing redundant store subscriptions * refactor(player): remove unused mode change handling in mask viewport hook - remove onModeChange callback from UseMaskViewportOptions - remove onModeChange parameter from hook implementation - eliminate previousMaskModeRef and mode change effect - clean up unused dependencies in hook implementation * fix(player/subtitle): clear selected text on subtitle change - Add useEffect to clear selected text when subtitle index changes - Prevent stale text selection when new subtitle appears * fix(player/subtitle): use correct height limit constant in normal mode - replace MAX_OVERLAY_WIDTH_PERCENT with MAX_OVERLAY_HEIGHT_PERCENT_NORMAL_MODE for heightLimit - ensure subtitle overlay height is properly limited to 40% in normal mode * fix(player/subtitle): update drag bounds when overlay size changes Add updateLatestSize function to useSubtitleDrag hook to ensure latestSizeRef is updated when overlay size changes. This fixes incorrect bottom boundary detection during drag operations after overlay height changes. - Add updateLatestSize callback in useSubtitleDrag hook - Use updateLatestSizeFromDrag in SubtitleOverlay component - Ensure drag boundary calculations use current overlay dimensions Fixes issue where overlay bottom boundary detection was incorrect after height changes, as drag calculations were using stale size references. * chore(test): remove SubtitleDictionaryLookup.test.tsx - remove SubtitleDictionaryLookup.test.tsx - this test file is no longer needed
1 parent fdf6ab0 commit 7b749a7

18 files changed

Lines changed: 1719 additions & 1202 deletions

src/renderer/src/pages/player/components/SubtitleContent.tsx

Lines changed: 167 additions & 111 deletions
Large diffs are not rendered by default.

src/renderer/src/pages/player/components/SubtitleOverlay.tsx

Lines changed: 176 additions & 806 deletions
Large diffs are not rendered by default.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* SubtitleResizeHandle Component
3+
*
4+
* 独立的调整尺寸句柄组件:
5+
* - 只在遮罩模式下渲染
6+
* - 包含 Tooltip
7+
* - 处理鼠标事件
8+
* - 包含样式定义
9+
* - 支持主题变量适配
10+
*/
11+
12+
import { ANIMATION_DURATION, EASING } from '@renderer/infrastructure/styles/theme'
13+
import { Tooltip } from 'antd'
14+
import React, { memo } from 'react'
15+
import { useTranslation } from 'react-i18next'
16+
import styled from 'styled-components'
17+
18+
export interface SubtitleResizeHandleProps {
19+
/** 句柄是否可见 */
20+
visible: boolean
21+
/** 鼠标按下事件处理 */
22+
onMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void
23+
/** 双击事件处理 */
24+
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void
25+
/** 自定义 className */
26+
className?: string
27+
/** 测试 ID */
28+
testId?: string
29+
}
30+
31+
/**
32+
* 调整尺寸句柄组件
33+
*/
34+
export const SubtitleResizeHandle = memo(function SubtitleResizeHandle({
35+
visible,
36+
onMouseDown,
37+
onDoubleClick,
38+
className,
39+
testId = 'subtitle-resize-handle'
40+
}: SubtitleResizeHandleProps) {
41+
const { t } = useTranslation()
42+
43+
if (!visible) {
44+
return null
45+
}
46+
47+
return (
48+
<Tooltip
49+
title={t('settings.playback.subtitle.overlay.resizeHandle.tooltip')}
50+
placement="top"
51+
mouseEnterDelay={0.5}
52+
mouseLeaveDelay={0}
53+
>
54+
<ResizeHandle
55+
className={className}
56+
$visible={visible}
57+
onMouseDown={onMouseDown}
58+
onDoubleClick={onDoubleClick}
59+
data-testid={testId}
60+
/>
61+
</Tooltip>
62+
)
63+
})
64+
65+
export default SubtitleResizeHandle
66+
67+
// === 样式组件 ===
68+
69+
const ResizeHandle = styled.div<{ $visible: boolean }>`
70+
position: absolute;
71+
bottom: -4px;
72+
right: -4px;
73+
width: 12px;
74+
height: 12px;
75+
76+
/* 使用主题变量支持主题切换 */
77+
background: var(--ant-color-primary, rgba(102, 126, 234, 0.8));
78+
border: 2px solid var(--ant-color-white, rgba(255, 255, 255, 0.9));
79+
border-radius: 50%;
80+
cursor: nw-resize;
81+
82+
opacity: ${(props) => (props.$visible ? 1 : 0)};
83+
transition: all ${ANIMATION_DURATION.MEDIUM} ${EASING.STANDARD};
84+
85+
&:hover {
86+
background: var(--ant-color-primary-hover, var(--ant-color-primary));
87+
transform: scale(1.2);
88+
89+
/* 使用主题变量 */
90+
box-shadow: var(--ant-box-shadow-secondary, 0 2px 8px rgba(102, 126, 234, 0.4));
91+
}
92+
93+
&:active {
94+
transform: scale(1.1);
95+
}
96+
`
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* SubtitleToast Component
3+
*
4+
* 独立的 Toast 通知组件:
5+
* - 接收 visible 和 message props
6+
* - 包含样式组件定义
7+
* - 支持自动隐藏逻辑
8+
* - 使用主题变量适配深色/浅色模式
9+
*/
10+
11+
import {
12+
ANIMATION_DURATION,
13+
BORDER_RADIUS,
14+
EASING,
15+
FONT_SIZES,
16+
FONT_WEIGHTS,
17+
GLASS_EFFECT,
18+
SHADOWS,
19+
SPACING,
20+
Z_INDEX
21+
} from '@renderer/infrastructure/styles/theme'
22+
import { memo } from 'react'
23+
import styled from 'styled-components'
24+
25+
export interface SubtitleToastProps {
26+
/** Toast 是否可见 */
27+
visible: boolean
28+
/** Toast 消息内容 */
29+
message: string
30+
/** 自动隐藏延迟时间(毫秒),0 表示不自动隐藏 */
31+
autoHideDelay?: number
32+
/** onAutoHide?: () => void - 可选的自动隐藏回调 */
33+
}
34+
35+
/**
36+
* Toast 通知组件
37+
*/
38+
export const SubtitleToast = memo(function SubtitleToast({ visible, message }: SubtitleToastProps) {
39+
if (!visible || !message) {
40+
return null
41+
}
42+
43+
return (
44+
<ToastContainer $visible={visible} role="status" aria-live="polite" aria-atomic="true">
45+
<ToastContent>{message}</ToastContent>
46+
</ToastContainer>
47+
)
48+
})
49+
50+
export default SubtitleToast
51+
52+
// === 样式组件 ===
53+
54+
const ToastContainer = styled.div<{ $visible: boolean }>`
55+
position: absolute;
56+
top: -40px;
57+
left: 50%;
58+
transform: translateX(-50%);
59+
opacity: ${(props) => (props.$visible ? 1 : 0)};
60+
visibility: ${(props) => (props.$visible ? 'visible' : 'hidden')};
61+
transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE};
62+
z-index: ${Z_INDEX.MODAL};
63+
pointer-events: none;
64+
`
65+
66+
const ToastContent = styled.div`
67+
/* 使用 CSS 变量支持主题切换 */
68+
background: var(--ant-color-bg-elevated, rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}));
69+
color: var(--ant-color-text, #ffffff);
70+
padding: ${SPACING.XS}px ${SPACING.MD}px;
71+
border-radius: ${BORDER_RADIUS.SM}px;
72+
font-size: ${FONT_SIZES.SM}px;
73+
font-weight: ${FONT_WEIGHTS.MEDIUM};
74+
white-space: nowrap;
75+
backdrop-filter: blur(${GLASS_EFFECT.BLUR_STRENGTH.SUBTLE}px);
76+
border: 1px solid
77+
var(--ant-color-border, rgba(255, 255, 255, ${GLASS_EFFECT.BORDER_ALPHA.SUBTLE}));
78+
box-shadow: var(--ant-box-shadow-secondary, ${SHADOWS.SM});
79+
`

src/renderer/src/pages/player/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export { default as SettingsPopover } from './SettingsPopover'
1111
export { default as SubtitleContent } from './SubtitleContent'
1212
export { default as SubtitleListPanel } from './SubtitleListPanel'
1313
export { default as SubtitleOverlay } from './SubtitleOverlay'
14+
export { default as SubtitleResizeHandle } from './SubtitleResizeHandle'
15+
export { default as SubtitleToast } from './SubtitleToast'
1416
export { default as SubtitleTrackSelector } from './SubtitleTrackSelector'
1517
export { default as VideoErrorRecovery } from './VideoErrorRecovery'
1618
export { default as VideoSurface } from './VideoSurface'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
export { useContainerBounds } from './useContainerBounds'
2+
export { useContentWidth } from './useContentWidth'
3+
export { useMaskViewport } from './useMaskViewport'
14
export { usePlayerEngine } from './usePlayerEngine'
5+
export { useSubtitleDrag } from './useSubtitleDrag'
26
export { useSubtitleEngine } from './useSubtitleEngine'
37
export { useSubtitleOverlay } from './useSubtitleOverlay'
48
export { useSubtitleOverlayUI } from './useSubtitleOverlayUI'
9+
export { useSubtitleResize } from './useSubtitleResize'
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* useContainerBounds Hook
3+
*
4+
* 管理容器尺寸变化和冲突检测:
5+
* - 容器边界更新
6+
* - ResizeObserver 监听
7+
* - 冲突区域检测
8+
* - 智能避让逻辑
9+
*/
10+
11+
import { SubtitleDisplayMode } from '@types'
12+
import { useCallback, useEffect } from 'react'
13+
14+
export interface ConflictArea {
15+
x: number
16+
y: number
17+
width: number
18+
height: number
19+
}
20+
21+
export interface UseContainerBoundsOptions {
22+
containerRef?: React.RefObject<HTMLElement | null>
23+
displayMode: SubtitleDisplayMode
24+
currentConfig?: { isInitialized?: boolean }
25+
updateContainerBounds: (bounds: { width: number; height: number }) => void
26+
adaptToContainerResize: (bounds: { width: number; height: number }) => void
27+
avoidCollision: (conflicts: ConflictArea[]) => void
28+
}
29+
30+
export function useContainerBounds({
31+
containerRef,
32+
displayMode,
33+
currentConfig,
34+
updateContainerBounds,
35+
adaptToContainerResize,
36+
avoidCollision
37+
}: UseContainerBoundsOptions) {
38+
// === 容器边界更新 ===
39+
useEffect(() => {
40+
let resizeTimer: NodeJS.Timeout
41+
42+
const updateBounds = (isInitial = false) => {
43+
const container =
44+
containerRef?.current || document.querySelector('[data-testid="video-surface"]')
45+
if (container) {
46+
const rect = container.getBoundingClientRect()
47+
const newBounds = { width: rect.width, height: rect.height }
48+
49+
if (isInitial || !currentConfig?.isInitialized) {
50+
// 初始化时使用 updateContainerBounds
51+
updateContainerBounds(newBounds)
52+
} else {
53+
// 容器尺寸变化时使用智能适应
54+
adaptToContainerResize(newBounds)
55+
}
56+
}
57+
}
58+
59+
const handleResize = () => {
60+
// 防抖处理,避免频繁重新计算
61+
clearTimeout(resizeTimer)
62+
resizeTimer = setTimeout(() => updateBounds(false), 150)
63+
}
64+
65+
// 初始化
66+
updateBounds(true)
67+
68+
// 监听窗口尺寸变化
69+
window.addEventListener('resize', handleResize)
70+
71+
// 监听全屏模式变化(可能导致容器尺寸变化)
72+
let observer: ResizeObserver | null = null
73+
74+
if (typeof ResizeObserver !== 'undefined') {
75+
observer = new ResizeObserver((entries) => {
76+
for (const entry of entries) {
77+
if (
78+
entry.target ===
79+
(containerRef?.current || document.querySelector('[data-testid="video-surface"]'))
80+
) {
81+
handleResize()
82+
}
83+
}
84+
})
85+
86+
const container =
87+
containerRef?.current || document.querySelector('[data-testid="video-surface"]')
88+
if (container) {
89+
observer.observe(container)
90+
}
91+
}
92+
93+
return () => {
94+
clearTimeout(resizeTimer)
95+
window.removeEventListener('resize', handleResize)
96+
if (observer && typeof observer.disconnect === 'function') {
97+
observer.disconnect()
98+
}
99+
}
100+
}, [containerRef, updateContainerBounds, adaptToContainerResize, currentConfig?.isInitialized])
101+
102+
// === 冲突区域检测 ===
103+
const detectConflictAreas = useCallback((): ConflictArea[] => {
104+
const conflictSelectors = [
105+
'[data-testid="controller-panel"]',
106+
'[data-testid="transport-bar"]',
107+
'[aria-label="transport-bar"]',
108+
'.progress-section',
109+
'.controller-panel'
110+
]
111+
112+
const conflictAreas: ConflictArea[] = []
113+
const container =
114+
containerRef?.current || document.querySelector('[data-testid="video-surface"]')
115+
116+
if (!container) return conflictAreas
117+
118+
const containerRect = container.getBoundingClientRect()
119+
120+
conflictSelectors.forEach((selector) => {
121+
const element = document.querySelector(selector) as HTMLElement
122+
if (element && element.offsetParent) {
123+
// 确保元素可见
124+
const rect = element.getBoundingClientRect()
125+
126+
// 转换为相对于容器的百分比坐标
127+
const relativeArea = {
128+
x: ((rect.left - containerRect.left) / containerRect.width) * 100,
129+
y: ((rect.top - containerRect.top) / containerRect.height) * 100,
130+
width: (rect.width / containerRect.width) * 100,
131+
height: (rect.height / containerRect.height) * 100
132+
}
133+
134+
// 只关注可能与字幕区域重叠的元素
135+
if (relativeArea.y > 50) {
136+
// 只检测下半部分
137+
conflictAreas.push(relativeArea)
138+
}
139+
}
140+
})
141+
142+
return conflictAreas
143+
}, [containerRef])
144+
145+
// === 智能冲突检测 ===
146+
useEffect(() => {
147+
// 定期检测冲突区域(UI 元素可能动态显示/隐藏)
148+
const conflictCheckTimer = setInterval(() => {
149+
if (displayMode !== SubtitleDisplayMode.NONE) {
150+
const conflicts = detectConflictAreas()
151+
if (conflicts.length > 0) {
152+
avoidCollision(conflicts)
153+
}
154+
}
155+
}, 2000) // 每 2 秒检测一次
156+
157+
return () => {
158+
clearInterval(conflictCheckTimer)
159+
}
160+
}, [detectConflictAreas, displayMode, avoidCollision])
161+
162+
return {
163+
detectConflictAreas
164+
}
165+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* useContentWidth Hook
3+
*
4+
* 采用 YouTube 风格的字幕宽度控制方案:
5+
* - 使用 CSS 的 max-content (fit-content) 让内容自然决定宽度
6+
* - 通过 max-width 限制最大宽度为 95%
7+
* - 完全由 CSS 处理,无需 JavaScript 计算
8+
*
9+
* 优势:
10+
* - ✅ 零 JavaScript 计算开销
11+
* - ✅ 无渲染闪烁
12+
* - ✅ 单行内容:容器宽度 = 内容宽度
13+
* - ✅ 多行内容:容器宽度 = 95%(自动换行)
14+
*/
15+
16+
export interface UseContentWidthOptions {
17+
/** 容器最大宽度百分比 */
18+
maxContainerWidthPercent?: number
19+
}
20+
21+
export const useContentWidth = ({ maxContainerWidthPercent = 95 }: UseContentWidthOptions) => {
22+
// 返回固定的 CSS 宽度策略
23+
// width: max-content 让容器完全根据内容宽度扩展(不提前换行)
24+
// max-width: 95% 限制最大宽度
25+
// 当内容超过 max-width 时,容器会被限制,内容自然换行
26+
const widthStyle = 'max-content'
27+
const maxWidthStyle = `${maxContainerWidthPercent}%`
28+
29+
return {
30+
widthStyle,
31+
maxWidthStyle
32+
}
33+
}

0 commit comments

Comments
 (0)