-
Notifications
You must be signed in to change notification settings - Fork 2
refactor(player/subtitle): comprehensive subtitle overlay improvements #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
3c71053
refactor(player): extract subtitle overlay logic into separate hooks …
mkdir700 5d31ecc
refactor(player): enhance subtitle styling and introduce content widt…
mkdir700 4779b03
refactor(player): improve drag bounds calculation for mask mode
mkdir700 26fda5e
refactor(subtitle-overlay): show resize handle only on hover in mask …
mkdir700 bd4d51a
fix(player/subtitle): correct token rendering and selection logic
mkdir700 746a6bb
refactor(subtitle-overlay): centralize selected text state management
mkdir700 af4e319
fix(player/subtitle): resolve toast display conflicts and coordinate …
mkdir700 bb9b626
fix(player/subtitle): remove unused heightLimit variable to resolve T…
mkdir700 fb416eb
fix(player/subtitle): type resize handle events for div
mkdir700 f7bd1d3
refactor(player/subtitle): remove duplicate useSubtitleOverlay calls …
mkdir700 3d07402
refactor(player): remove unused mode change handling in mask viewport…
mkdir700 1b2030a
fix(player/subtitle): clear selected text on subtitle change
mkdir700 2847dab
fix(player/subtitle): use correct height limit constant in normal mode
mkdir700 db997bb
fix(player/subtitle): update drag bounds when overlay size changes
mkdir700 c673042
chore(test): remove SubtitleDictionaryLookup.test.tsx
mkdir700 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
288 changes: 181 additions & 107 deletions
288
src/renderer/src/pages/player/components/SubtitleContent.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
967 changes: 165 additions & 802 deletions
967
src/renderer/src/pages/player/components/SubtitleOverlay.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
96 changes: 96 additions & 0 deletions
96
src/renderer/src/pages/player/components/SubtitleResizeHandle.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| /** 自定义 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> | ||
|
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); | ||
| } | ||
| ` | ||
|
mkdir700 marked this conversation as resolved.
|
||
79 changes: 79 additions & 0 deletions
79
src/renderer/src/pages/player/components/SubtitleToast.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 - 可选的自动隐藏回调 */ | ||
| } | ||
|
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}); | ||
| ` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
165
src/renderer/src/pages/player/hooks/useContainerBounds.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 浏览器环境下 Timer 类型不当与防抖清理不安全;补充零尺寸保护
- 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 conflictAreasAlso applies to: 93-101, 118-132 🤖 Prompt for AI Agents |
||
|
|
||
| // 初始化 | ||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
|
mkdir700 marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.