Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 181 additions & 107 deletions src/renderer/src/pages/player/components/SubtitleContent.tsx

Large diffs are not rendered by default.

967 changes: 165 additions & 802 deletions src/renderer/src/pages/player/components/SubtitleOverlay.tsx

Large diffs are not rendered by default.

96 changes: 96 additions & 0 deletions src/renderer/src/pages/player/components/SubtitleResizeHandle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* SubtitleResizeHandle Component
*
* 独立的调整尺寸句柄组件:
* - 只在遮罩模式下渲染
* - 包含 Tooltip
* - 处理鼠标事件
* - 包含样式定义
* - 支持主题变量适配
*/

import { ANIMATION_DURATION, EASING } from '@renderer/infrastructure/styles/theme'
import { Tooltip } from 'antd'
import React, { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'

export interface SubtitleResizeHandleProps {
/** 句柄是否可见 */
visible: boolean
/** 鼠标按下事件处理 */
onMouseDown: (event: React.MouseEvent) => void
/** 双击事件处理 */
onDoubleClick: (event: React.MouseEvent) => void
Comment thread
mkdir700 marked this conversation as resolved.
Outdated
/** 自定义 className */
className?: string
/** 测试 ID */
testId?: string
}

/**
* 调整尺寸句柄组件
*/
export const SubtitleResizeHandle = memo(function SubtitleResizeHandle({
visible,
onMouseDown,
onDoubleClick,
className,
testId = 'subtitle-resize-handle'
}: SubtitleResizeHandleProps) {
const { t } = useTranslation()

if (!visible) {
return null
}

return (
<Tooltip
title={t('settings.playback.subtitle.overlay.resizeHandle.tooltip')}
placement="top"
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
>
<ResizeHandle
className={className}
$visible={visible}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
data-testid={testId}
/>
</Tooltip>
Comment thread
mkdir700 marked this conversation as resolved.
)
})

export default SubtitleResizeHandle

// === 样式组件 ===

const ResizeHandle = styled.div<{ $visible: boolean }>`
position: absolute;
bottom: -4px;
right: -4px;
width: 12px;
height: 12px;

/* 使用主题变量支持主题切换 */
background: var(--ant-color-primary, rgba(102, 126, 234, 0.8));
border: 2px solid var(--ant-color-white, rgba(255, 255, 255, 0.9));
border-radius: 50%;
cursor: nw-resize;

opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: all ${ANIMATION_DURATION.MEDIUM} ${EASING.STANDARD};

&:hover {
background: var(--ant-color-primary-hover, var(--ant-color-primary));
transform: scale(1.2);

/* 使用主题变量 */
box-shadow: var(--ant-box-shadow-secondary, 0 2px 8px rgba(102, 126, 234, 0.4));
}

&:active {
transform: scale(1.1);
}
`
Comment thread
mkdir700 marked this conversation as resolved.
79 changes: 79 additions & 0 deletions src/renderer/src/pages/player/components/SubtitleToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* SubtitleToast Component
*
* 独立的 Toast 通知组件:
* - 接收 visible 和 message props
* - 包含样式组件定义
* - 支持自动隐藏逻辑
* - 使用主题变量适配深色/浅色模式
*/

import {
ANIMATION_DURATION,
BORDER_RADIUS,
EASING,
FONT_SIZES,
FONT_WEIGHTS,
GLASS_EFFECT,
SHADOWS,
SPACING,
Z_INDEX
} from '@renderer/infrastructure/styles/theme'
import { memo } from 'react'
import styled from 'styled-components'

export interface SubtitleToastProps {
/** Toast 是否可见 */
visible: boolean
/** Toast 消息内容 */
message: string
/** 自动隐藏延迟时间(毫秒),0 表示不自动隐藏 */
autoHideDelay?: number
/** onAutoHide?: () => void - 可选的自动隐藏回调 */
}
Comment thread
mkdir700 marked this conversation as resolved.

/**
* Toast 通知组件
*/
export const SubtitleToast = memo(function SubtitleToast({ visible, message }: SubtitleToastProps) {
if (!visible || !message) {
return null
}

return (
<ToastContainer $visible={visible} role="status" aria-live="polite" aria-atomic="true">
<ToastContent>{message}</ToastContent>
</ToastContainer>
)
})

export default SubtitleToast

// === 样式组件 ===

const ToastContainer = styled.div<{ $visible: boolean }>`
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
opacity: ${(props) => (props.$visible ? 1 : 0)};
visibility: ${(props) => (props.$visible ? 'visible' : 'hidden')};
transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE};
z-index: ${Z_INDEX.MODAL};
pointer-events: none;
`

const ToastContent = styled.div`
/* 使用 CSS 变量支持主题切换 */
background: var(--ant-color-bg-elevated, rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}));
color: var(--ant-color-text, #ffffff);
padding: ${SPACING.XS}px ${SPACING.MD}px;
border-radius: ${BORDER_RADIUS.SM}px;
font-size: ${FONT_SIZES.SM}px;
font-weight: ${FONT_WEIGHTS.MEDIUM};
white-space: nowrap;
backdrop-filter: blur(${GLASS_EFFECT.BLUR_STRENGTH.SUBTLE}px);
border: 1px solid
var(--ant-color-border, rgba(255, 255, 255, ${GLASS_EFFECT.BORDER_ALPHA.SUBTLE}));
box-shadow: var(--ant-box-shadow-secondary, ${SHADOWS.SM});
`
2 changes: 2 additions & 0 deletions src/renderer/src/pages/player/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export { default as SettingsPopover } from './SettingsPopover'
export { default as SubtitleContent } from './SubtitleContent'
export { default as SubtitleListPanel } from './SubtitleListPanel'
export { default as SubtitleOverlay } from './SubtitleOverlay'
export { default as SubtitleResizeHandle } from './SubtitleResizeHandle'
export { default as SubtitleToast } from './SubtitleToast'
export { default as SubtitleTrackSelector } from './SubtitleTrackSelector'
export { default as VideoErrorRecovery } from './VideoErrorRecovery'
export { default as VideoSurface } from './VideoSurface'
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/pages/player/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { useContainerBounds } from './useContainerBounds'
export { useContentWidth } from './useContentWidth'
export { useMaskViewport } from './useMaskViewport'
export { usePlayerEngine } from './usePlayerEngine'
export { useSubtitleDrag } from './useSubtitleDrag'
export { useSubtitleEngine } from './useSubtitleEngine'
export { useSubtitleOverlay } from './useSubtitleOverlay'
export { useSubtitleOverlayUI } from './useSubtitleOverlayUI'
export { useSubtitleResize } from './useSubtitleResize'
165 changes: 165 additions & 0 deletions src/renderer/src/pages/player/hooks/useContainerBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* useContainerBounds Hook
*
* 管理容器尺寸变化和冲突检测:
* - 容器边界更新
* - ResizeObserver 监听
* - 冲突区域检测
* - 智能避让逻辑
*/

import { SubtitleDisplayMode } from '@types'
import { useCallback, useEffect } from 'react'

export interface ConflictArea {
x: number
y: number
width: number
height: number
}

export interface UseContainerBoundsOptions {
containerRef?: React.RefObject<HTMLElement | null>
displayMode: SubtitleDisplayMode
currentConfig?: { isInitialized?: boolean }
updateContainerBounds: (bounds: { width: number; height: number }) => void
adaptToContainerResize: (bounds: { width: number; height: number }) => void
avoidCollision: (conflicts: ConflictArea[]) => void
}

export function useContainerBounds({
containerRef,
displayMode,
currentConfig,
updateContainerBounds,
adaptToContainerResize,
avoidCollision
}: UseContainerBoundsOptions) {
// === 容器边界更新 ===
useEffect(() => {
let resizeTimer: NodeJS.Timeout

const updateBounds = (isInitial = false) => {
const container =
containerRef?.current || document.querySelector('[data-testid="video-surface"]')
if (container) {
const rect = container.getBoundingClientRect()
const newBounds = { width: rect.width, height: rect.height }

if (isInitial || !currentConfig?.isInitialized) {
// 初始化时使用 updateContainerBounds
updateContainerBounds(newBounds)
} else {
// 容器尺寸变化时使用智能适应
adaptToContainerResize(newBounds)
}
}
}

const handleResize = () => {
// 防抖处理,避免频繁重新计算
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => updateBounds(false), 150)
}
Comment on lines +40 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

浏览器环境下 Timer 类型不当与防抖清理不安全;补充零尺寸保护

  • NodeJS.Timeout 在 DOM 环境中不可靠;建议使用 ReturnType<typeof setTimeout>
  • 首次调用时 clearTimeout(resizeTimer) 可能为 undefined
  • 当容器宽高为 0 时应跳过冲突换算,避免除以 0。
-    let resizeTimer: NodeJS.Timeout
+    let resizeTimer: ReturnType<typeof setTimeout> | undefined
...
-      clearTimeout(resizeTimer)
-      resizeTimer = setTimeout(() => updateBounds(false), 150)
+      if (resizeTimer) clearTimeout(resizeTimer)
+      resizeTimer = window.setTimeout(() => updateBounds(false), 150)
...
-      clearTimeout(resizeTimer)
+      if (resizeTimer) clearTimeout(resizeTimer)

并在冲突检测中加入尺寸校验:

-    const containerRect = container.getBoundingClientRect()
+    const containerRect = container.getBoundingClientRect()
+    if (containerRect.width <= 0 || containerRect.height <= 0) return conflictAreas

Also applies to: 93-101, 118-132

🤖 Prompt for AI Agents
In src/renderer/src/pages/player/hooks/useContainerBounds.ts around lines 40-63
(also apply same fixes at 93-101 and 118-132): the timer type and defensive
checks are unsafe for browser DOM and the resize logic doesn't guard against
zero dimensions; change the timer type from NodeJS.Timeout to ReturnType<typeof
setTimeout> and initialize it as possibly undefined, only call clearTimeout if
the timer is set, and ensure updateBounds skips calling updateContainerBounds or
adaptToContainerResize when rect.width or rect.height is 0 (return early) to
avoid divide-by-zero or invalid calculations during conflict detection; apply
identical safe-guarding and type fixes to the other mentioned line ranges.


// 初始化
updateBounds(true)

// 监听窗口尺寸变化
window.addEventListener('resize', handleResize)

// 监听全屏模式变化(可能导致容器尺寸变化)
let observer: ResizeObserver | null = null

if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (
entry.target ===
(containerRef?.current || document.querySelector('[data-testid="video-surface"]'))
) {
handleResize()
}
}
})

const container =
containerRef?.current || document.querySelector('[data-testid="video-surface"]')
if (container) {
observer.observe(container)
}
}

return () => {
clearTimeout(resizeTimer)
window.removeEventListener('resize', handleResize)
if (observer && typeof observer.disconnect === 'function') {
observer.disconnect()
}
}
}, [containerRef, updateContainerBounds, adaptToContainerResize, currentConfig?.isInitialized])

// === 冲突区域检测 ===
const detectConflictAreas = useCallback((): ConflictArea[] => {
const conflictSelectors = [
'[data-testid="controller-panel"]',
'[data-testid="transport-bar"]',
'[aria-label="transport-bar"]',
'.progress-section',
'.controller-panel'
]

const conflictAreas: ConflictArea[] = []
const container =
containerRef?.current || document.querySelector('[data-testid="video-surface"]')

if (!container) return conflictAreas

const containerRect = container.getBoundingClientRect()

conflictSelectors.forEach((selector) => {
const element = document.querySelector(selector) as HTMLElement
if (element && element.offsetParent) {
// 确保元素可见
const rect = element.getBoundingClientRect()

// 转换为相对于容器的百分比坐标
const relativeArea = {
x: ((rect.left - containerRect.left) / containerRect.width) * 100,
y: ((rect.top - containerRect.top) / containerRect.height) * 100,
width: (rect.width / containerRect.width) * 100,
height: (rect.height / containerRect.height) * 100
}

// 只关注可能与字幕区域重叠的元素
if (relativeArea.y > 50) {
// 只检测下半部分
conflictAreas.push(relativeArea)
}
}
})

return conflictAreas
}, [containerRef])

// === 智能冲突检测 ===
useEffect(() => {
// 定期检测冲突区域(UI 元素可能动态显示/隐藏)
const conflictCheckTimer = setInterval(() => {
if (displayMode !== SubtitleDisplayMode.NONE) {
const conflicts = detectConflictAreas()
if (conflicts.length > 0) {
avoidCollision(conflicts)
}
}
}, 2000) // 每 2 秒检测一次

return () => {
clearInterval(conflictCheckTimer)
}
}, [detectConflictAreas, displayMode, avoidCollision])

return {
detectConflictAreas
}
}
33 changes: 33 additions & 0 deletions src/renderer/src/pages/player/hooks/useContentWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* useContentWidth Hook
*
* 采用 YouTube 风格的字幕宽度控制方案:
* - 使用 CSS 的 max-content (fit-content) 让内容自然决定宽度
* - 通过 max-width 限制最大宽度为 95%
* - 完全由 CSS 处理,无需 JavaScript 计算
*
* 优势:
* - ✅ 零 JavaScript 计算开销
* - ✅ 无渲染闪烁
* - ✅ 单行内容:容器宽度 = 内容宽度
* - ✅ 多行内容:容器宽度 = 95%(自动换行)
*/

export interface UseContentWidthOptions {
/** 容器最大宽度百分比 */
maxContainerWidthPercent?: number
}

export const useContentWidth = ({ maxContainerWidthPercent = 95 }: UseContentWidthOptions) => {
// 返回固定的 CSS 宽度策略
// width: max-content 让容器完全根据内容宽度扩展(不提前换行)
// max-width: 95% 限制最大宽度
// 当内容超过 max-width 时,容器会被限制,内容自然换行
const widthStyle = 'max-content'
const maxWidthStyle = `${maxContainerWidthPercent}%`

return {
widthStyle,
maxWidthStyle
}
}
Comment thread
mkdir700 marked this conversation as resolved.
Loading
Loading