From f58412044061d618b9512d48e0f54af6c1f0a675 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 15 Oct 2025 18:58:03 +0800 Subject: [PATCH 1/2] feat(player): add subtitle search functionality - Implement subtitle search feature across multiple locales - Add new translations for subtitle search in en-us, ja-jp, ru-ru, and zh-cn - Create SubtitleSearchHighlight component for highlighting search results - Update player-ui store to manage subtitle search visibility state - Introduce actions to toggle, show, and hide subtitle search --- src/renderer/src/i18n/locales/en-us.json | 16 + src/renderer/src/i18n/locales/ja-jp.json | 18 + src/renderer/src/i18n/locales/ru-ru.json | 20 + src/renderer/src/i18n/locales/zh-cn.json | 99 ++-- src/renderer/src/i18n/locales/zh-tw.json | 18 + .../src/infrastructure/hooks/useShortcut.ts | 64 ++- src/renderer/src/pages/player/PlayerPage.tsx | 10 +- .../player/components/SubtitleListPanel.tsx | 528 +++++++++++++++--- .../components/SubtitleSearchHighlight.tsx | 57 ++ .../src/state/stores/player-ui.store.ts | 27 + 10 files changed, 739 insertions(+), 118 deletions(-) create mode 100644 src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e54a6622..6d470bbd 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -86,6 +86,22 @@ } } } + }, + "subtitleList": { + "empty": { + "title": "No matching subtitle file found", + "description": "You can choose a subtitle file with the button below or drag a subtitle file into this area." + }, + "search": { + "placeholder": "Search subtitles...", + "pending": "Searching...", + "count": "Found {{count}} subtitle", + "count_one": "Found {{count}} subtitle", + "count_other": "Found {{count}} subtitles", + "none": "No subtitles match your search", + "emptyTitle": "No matches found", + "emptySubtitle": "Try another keyword" + } } }, "settings": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5c7d91ce..cd09b2cf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -134,5 +134,23 @@ "show_mini_window": "快速助手", "show_window": "表示ウィンドウ" } + }, + "player": { + "subtitleList": { + "empty": { + "title": "動画ファイルと同じフォルダーに一致する字幕ファイルが見つかりません", + "description": "下のボタンから字幕ファイルを選択するか、このエリアにドラッグしてください" + }, + "search": { + "placeholder": "字幕を検索...", + "pending": "検索中...", + "count": "{{count}} 件の字幕が見つかりました", + "count_one": "{{count}} 件の字幕が見つかりました", + "count_other": "{{count}} 件の字幕が見つかりました", + "none": "一致する字幕が見つかりません", + "emptyTitle": "一致する結果がありません", + "emptySubtitle": "別のキーワードを試してください" + } + } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 73f93d81..eeaac915 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -94,5 +94,25 @@ "show_mini_window": "Быстрый помощник", "show_window": "отображать окно" } + }, + "player": { + "subtitleList": { + "empty": { + "title": "В той же папке, что и видео, не найден подходящий файл субтитров", + "description": "Вы можете выбрать файл субтитров с помощью кнопки ниже или перетащить его в эту область" + }, + "search": { + "placeholder": "Поиск субтитров...", + "pending": "Поиск...", + "count": "Найден {{count}} файл субтитров", + "count_one": "Найден {{count}} файл субтитров", + "count_few": "Найдено {{count}} файла субтитров", + "count_many": "Найдено {{count}} файлов субтитров", + "count_other": "Найдено {{count}} субтитров", + "none": "Субтитры по запросу не найдены", + "emptyTitle": "Совпадений не найдено", + "emptySubtitle": "Попробуйте другой запрос" + } + } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a1d995e3..e4b3843f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -85,20 +85,6 @@ "tooltip": "透明背景" } }, - "mask-mode": { - "background-locked": { - "tooltip": "遮罩模式下背景样式固定为高斯模糊" - }, - "disable": { - "tooltip": "关闭遮罩模式" - }, - "enable": { - "tooltip": "开启遮罩模式,锁定内嵌字幕区域" - }, - "label": "遮罩模式", - "onboarding": "拖动或调整字幕框以匹配视频中的内嵌字幕区域", - "title": "遮罩模式" - }, "display-mode": { "bilingual": { "label": "双语", @@ -117,9 +103,30 @@ "label": "译文", "tooltip": "仅显示译文字幕 (Ctrl+3)" } + }, + "mask-mode": { + "background-locked": { + "tooltip": "遮罩模式下背景样式固定为高斯模糊" + }, + "disable": { + "tooltip": "关闭遮罩模式" + }, + "enable": { + "tooltip": "开启遮罩模式,锁定内嵌字幕区域" + }, + "label": "遮罩模式", + "onboarding": "拖动或调整字幕框以匹配视频中的内嵌字幕区域", + "title": "遮罩模式" } } }, + "dictionary": { + "error": "查询失败", + "loading": "查询中...", + "more_definitions": "... 还有 {{count}} 个释义", + "pronunciation": "点击发音", + "translations": "常用翻译" + }, "errorRecovery": { "actions": { "backToHome": "返回首页", @@ -179,39 +186,49 @@ "label": "文件路径" } }, - "subtitles": { - "hide": "隐藏字幕列表", - "show": "展开字幕列表" - }, "mediaServerPrompt": { - "title": "视频格式不兼容", - "subtitle": "检测到当前视频格式不受支持", + "actions": { + "install": "立即安装", + "later": "稍后再说" + }, "benefits": { - "title": "安装 Media Server 可以解决此问题", "compatibility": { - "title": "完美兼容", - "description": "支持所有常见视频格式,包括高清和蓝光视频" - }, - "transcoding": { - "title": "实时转码", - "description": "后台自动转码,无需等待,即开即用" + "description": "支持所有常见视频格式,包括高清和蓝光视频", + "title": "完美兼容" }, "easySetup": { - "title": "一键安装", - "description": "自动化安装流程,无需手动配置" + "description": "自动化安装流程,无需手动配置", + "title": "一键安装" + }, + "title": "安装 Media Server 可以解决此问题", + "transcoding": { + "description": "后台自动转码,无需等待,即开即用", + "title": "实时转码" } }, - "actions": { - "install": "立即安装", - "later": "稍后再说" - } + "subtitle": "检测到当前视频格式不受支持", + "title": "视频格式不兼容" }, - "dictionary": { - "loading": "查询中...", - "error": "查询失败", - "pronunciation": "点击发音", - "more_definitions": "... 还有 {{count}} 个释义", - "translations": "常用翻译" + "subtitles": { + "hide": "隐藏字幕列表", + "search": "搜索字幕", + "show": "展开字幕列表" + }, + "subtitleList": { + "empty": { + "title": "在视频文件同目录下未找到匹配的字幕文件", + "description": "您可以点击下方按钮选择字幕文件,或将字幕文件拖拽到此区域" + }, + "search": { + "placeholder": "搜索字幕...", + "pending": "搜索中...", + "count": "找到 {{count}} 条字幕", + "count_one": "找到 {{count}} 条字幕", + "count_other": "找到 {{count}} 条字幕", + "none": "未找到匹配的字幕", + "emptyTitle": "未找到匹配结果", + "emptySubtitle": "请尝试其他关键词" + } } }, "search": { @@ -415,11 +432,11 @@ }, "description": "FFprobe 用于读取音视频元数据和流信息,帮助 EchoPlayer 验证文件状态并完成兼容性检测。", "download": { - "install": "安装", "cancel": "取消下载", "cancelled": "下载已取消", "downloading": "下载中", "failed": "下载失败,请重试", + "install": "安装", "success": "下载完成" }, "path": { @@ -489,12 +506,12 @@ "show_settings": "打开设置", "single_loop": "循环播放", "title": "快捷键", + "toggle_auto_pause": "切换自动暂停", "toggle_fullscreen": "切换全屏", "toggle_new_context": "清除上下文", "toggle_show_assistants": "切换助手显示", "toggle_show_topics": "切换话题显示", "toggle_subtitle_panel": "切换字幕面板", - "toggle_auto_pause": "切换自动暂停", "volume_down": "减小音量", "volume_up": "增大音量", "zoom_in": "放大界面", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a8df690c..d02b1cbf 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -95,5 +95,23 @@ "show_mini_window": "快速助手", "show_window": "顯示視窗" } + }, + "player": { + "subtitleList": { + "empty": { + "title": "在影片檔同目錄下未找到符合的字幕檔案", + "description": "您可以點擊下方按鈕選擇字幕檔,或將字幕檔拖曳到此區域" + }, + "search": { + "placeholder": "搜尋字幕...", + "pending": "搜尋中...", + "count": "找到 {{count}} 筆字幕", + "count_one": "找到 {{count}} 筆字幕", + "count_other": "找到 {{count}} 筆字幕", + "none": "未找到符合的字幕", + "emptyTitle": "找不到符合的結果", + "emptySubtitle": "請嘗試其他關鍵字" + } + } } } diff --git a/src/renderer/src/infrastructure/hooks/useShortcut.ts b/src/renderer/src/infrastructure/hooks/useShortcut.ts index 165b3f5c..60ecd4ff 100644 --- a/src/renderer/src/infrastructure/hooks/useShortcut.ts +++ b/src/renderer/src/infrastructure/hooks/useShortcut.ts @@ -10,12 +10,59 @@ interface UseShortcutOptions { enableOnFormTags?: boolean enabled?: boolean description?: string + allowWhenTyping?: boolean } const defaultOptions: UseShortcutOptions = { preventDefault: true, enableOnFormTags: true, - enabled: true + enabled: true, + allowWhenTyping: false +} + +const NON_TYPABLE_INPUT_TYPES = new Set([ + 'button', + 'checkbox', + 'color', + 'date', + 'datetime-local', + 'file', + 'hidden', + 'image', + 'month', + 'radio', + 'range', + 'reset', + 'submit', + 'time', + 'week' +]) + +function isTypingInput(element: Element | null): boolean { + if (!element || !(element instanceof HTMLElement)) { + return false + } + + if (element.isContentEditable) { + return true + } + + const tagName = element.tagName.toLowerCase() + + if (tagName === 'textarea') { + return true + } + + if (tagName === 'input') { + const el = element as HTMLInputElement + return !NON_TYPABLE_INPUT_TYPES.has((el.type || '').toLowerCase()) + } + + if (tagName === 'select') { + return true + } + + return false } export const useShortcut = ( @@ -43,17 +90,24 @@ export const useShortcut = ( useHotkeys( shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none', (e) => { + const activeElement = document.activeElement + const typingActive = + !options.allowWhenTyping && isTypingInput(activeElement) && e.key !== 'Escape' + + if (options.enabled === false || typingActive) { + return + } + if (options.preventDefault) { e.preventDefault() } - if (options.enabled !== false) { - callback(e) - } + + callback(e) }, { enableOnFormTags: options.enableOnFormTags, description: options.description || shortcutConfig?.key, - enabled: !!shortcutConfig?.enabled + enabled: options.enabled !== false && !!shortcutConfig?.enabled } ) } diff --git a/src/renderer/src/pages/player/PlayerPage.tsx b/src/renderer/src/pages/player/PlayerPage.tsx index cefbd268..f621fe30 100644 --- a/src/renderer/src/pages/player/PlayerPage.tsx +++ b/src/renderer/src/pages/player/PlayerPage.tsx @@ -12,6 +12,7 @@ import { PlayerSettingsService } from '@renderer/services/PlayerSettingsLoader' import { playerSettingsPersistenceService } from '@renderer/services/PlayerSettingsSaver' import { usePlayerStore } from '@renderer/state' import { usePlayerSessionStore } from '@renderer/state/stores/player-session.store' +import { usePlayerUIStore } from '@renderer/state/stores/player-ui.store' import { IpcChannel } from '@shared/IpcChannel' import { Layout, Tooltip } from 'antd' @@ -24,7 +25,7 @@ import { FONT_SIZES, SPACING } from '@renderer/infrastructure/styles/theme' -import { ArrowLeft, PanelRightClose, PanelRightOpen } from 'lucide-react' +import { ArrowLeft, PanelRightClose, PanelRightOpen, Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' @@ -93,6 +94,7 @@ function PlayerPage() { const { t } = useTranslation() const { subtitlePanelVisible, toggleSubtitlePanel } = usePlayerStore() + const toggleSubtitleSearch = usePlayerUIStore((s) => s.toggleSubtitleSearch) const [videoData, setVideoData] = useState(null) const [loading, setLoading] = useState(true) @@ -600,8 +602,14 @@ function PlayerPage() { {videoData.title} + + + + + {subtitlePanelVisible ? ( diff --git a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx index ec2e0589..fd06a32b 100644 --- a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx +++ b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx @@ -1,6 +1,10 @@ +import { BORDER_RADIUS, SPACING } from '@renderer/infrastructure/styles/theme' +import { usePlayerUIStore } from '@renderer/state/stores/player-ui.store' import type { SubtitleItem } from '@types' import { Button } from 'antd' -import { ReactNode, RefObject, useCallback, useEffect, useMemo, useRef } from 'react' +import { Loader2, Search, X } from 'lucide-react' +import { ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' import styled from 'styled-components' @@ -12,6 +16,7 @@ import { } from '../hooks/useSubtitleScrollStateMachine' import { useSubtitles } from '../state/player-context' import { ImportSubtitleButton } from './' +import HighlightedText from './SubtitleSearchHighlight' interface EmptyAction { key?: string @@ -30,6 +35,11 @@ interface SubtitleListPannelProps { emptyActions?: EmptyAction[] } +type SubtitleSearchResult = { + subtitle: SubtitleItem + index: number +} + function SubtitleListPanel({ emptyTitle, emptyDescription, @@ -46,6 +56,34 @@ function SubtitleListPanel({ const { orchestrator } = usePlayerEngine() const { currentIndex } = useSubtitleEngine() const { seekToSubtitle } = usePlayerCommands() + const { t } = useTranslation() + + // 从 store 读取搜索状态 + const isSearchVisible = usePlayerUIStore((s) => s.subtitleSearch.isSearchVisible) + const hideSubtitleSearch = usePlayerUIStore((s) => s.hideSubtitleSearch) + + // 搜索相关状态 + const [searchQuery, setSearchQuery] = useState('') + const [isSearching, setIsSearching] = useState(false) + const searchInputRef = useRef(null) + + const normalizedQuery = searchQuery.trim() + + const searchResults = useMemo(() => { + if (!normalizedQuery) { + return [] + } + + const query = normalizedQuery.toLowerCase() + return subtitles.reduce((acc, subtitle, index) => { + if (subtitle.originalText.toLowerCase().includes(query)) { + acc.push({ subtitle, index }) + } + return acc + }, []) + }, [subtitles, normalizedQuery]) + + const hasSearchQuery = normalizedQuery.length > 0 // 使用新的状态机Hook const { scrollState, handleItemClick, handleRangeChanged, initialize } = @@ -83,6 +121,61 @@ function SubtitleListPanel({ return `${minutes}:${seconds.toString().padStart(2, '0')}` }, []) + // 搜索功能相关函数 + const handleSearchClose = useCallback(() => { + hideSubtitleSearch() + setSearchQuery('') + }, [hideSubtitleSearch]) + + // 当搜索框打开时,自动聚焦 + useEffect(() => { + if (isSearchVisible) { + setTimeout(() => searchInputRef.current?.focus(), 100) + } + }, [isSearchVisible]) + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + }, []) + + const handleSearchClear = useCallback(() => { + setSearchQuery('') + searchInputRef.current?.focus() + }, []) + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.code === 'Space' || e.key === 'Spacebar') { + e.stopPropagation() + if (typeof e.nativeEvent.stopImmediatePropagation === 'function') { + e.nativeEvent.stopImmediatePropagation() + } + return + } + + // ESC 键关闭搜索 + if (e.key === 'Escape') { + handleSearchClose() + } + }, + [handleSearchClose] + ) + + // 搜索防抖 + useEffect(() => { + if (!hasSearchQuery) { + setIsSearching(false) + return + } + + setIsSearching(true) + const timeoutId = setTimeout(() => { + setIsSearching(false) + }, 300) + + return () => clearTimeout(timeoutId) + }, [hasSearchQuery, normalizedQuery]) + // 初始化状态机(当字幕加载完成时) useEffect(() => { if (subtitles.length > 0 && scrollState === SubtitleScrollState.DISABLED) { @@ -94,9 +187,9 @@ function SubtitleListPanel({ return ( - {emptyTitle ?? '在视频文件同目录下未找到匹配的字幕文件'} + {emptyTitle ?? t('player.subtitleList.empty.title')} - {emptyDescription ?? '您可以点击下方按钮选择字幕文件,或将字幕文件拖拽到此区域'} + {emptyDescription ?? t('player.subtitleList.empty.description')} {emptyActions && emptyActions.length > 0 && ( @@ -123,78 +216,129 @@ function SubtitleListPanel({ return ( - - ( - handleItemClick(index)} - > - - {formatTime(subtitle.startTime)} - {formatTime(subtitle.endTime)} - - {subtitle.originalText} - + {/* Control Bar - 搜索控制栏(仅在搜索激活时显示)*/} + {isSearchVisible && ( + + + + {isSearching ? : } + + + {searchQuery && ( + + + + )} + + + + + + )} + + {/* 搜索结果提示 */} + {hasSearchQuery && ( + + {isSearching ? ( + {t('player.subtitleList.search.pending')} + ) : searchResults.length > 0 ? ( + {t('player.subtitleList.search.count', { count: searchResults.length })} + ) : ( + {t('player.subtitleList.search.none')} )} - components={{}} - alignToBottom - increaseViewportBy={{ top: 160, bottom: 160 }} - computeItemKey={(index, s: SubtitleItem) => s.id ?? index} - atTopThreshold={24} - atBottomThreshold={24} - atTopStateChange={() => { - // 状态由状态机管理,这里暂时保留但不执行任何操作 - }} - atBottomStateChange={() => { - // 状态由状态机管理,这里暂时保留但不执行任何操作 - }} - scrollerRef={(ref) => { - const el = (ref as HTMLElement) ?? null - scrollerElRef.current = el - }} - rangeChanged={({ startIndex, endIndex }) => { - const scroller = scrollerElRef.current - if (scroller) { - avgItemHeightRef.current = Math.max( - 56, - scroller.clientHeight / Math.max(1, endIndex - startIndex + 1) - ) - } + + )} - // 初次挂载后,将初始索引项滚动到垂直居中(只执行一次) - if ( - !initialCenterAppliedRef.current && - initialIndexRef.current !== null && - initialIndexRef.current >= 0 && - virtuosoRef.current - ) { - initialCenterAppliedRef.current = true - virtuosoRef.current.scrollToIndex({ - index: initialIndexRef.current, - align: 'center', - behavior: 'auto' - }) - return + + {hasSearchQuery ? ( + + ) : ( + ( + handleItemClick(index)} + > + + {formatTime(subtitle.startTime)} + {formatTime(subtitle.endTime)} + + {subtitle.originalText} + + )} + components={{}} + alignToBottom + increaseViewportBy={{ top: 160, bottom: 160 }} + computeItemKey={(index, s: SubtitleItem) => s.id ?? index} + atTopThreshold={24} + atBottomThreshold={24} + atTopStateChange={() => { + // 状态由状态机管理,这里暂时保留但不执行任何操作 + }} + atBottomStateChange={() => { + // 状态由状态机管理,这里暂时保留但不执行任何操作 + }} + scrollerRef={(ref) => { + const el = (ref as HTMLElement) ?? null + scrollerElRef.current = el + }} + rangeChanged={({ startIndex, endIndex }) => { + const scroller = scrollerElRef.current + if (scroller) { + avgItemHeightRef.current = Math.max( + 56, + scroller.clientHeight / Math.max(1, endIndex - startIndex + 1) + ) + } + + // 初次挂载后,将初始索引项滚动到垂直居中(只执行一次) + if ( + !initialCenterAppliedRef.current && + initialIndexRef.current !== null && + initialIndexRef.current >= 0 && + virtuosoRef.current + ) { + initialCenterAppliedRef.current = true + virtuosoRef.current.scrollToIndex({ + index: initialIndexRef.current, + align: 'center', + behavior: 'auto' + }) + return + } - // 使用新的状态机处理范围变化 - handleRangeChanged({ startIndex, endIndex }) - }} - /> + // 使用新的状态机处理范围变化 + handleRangeChanged({ startIndex, endIndex }) + }} + /> + )} ) @@ -202,6 +346,57 @@ function SubtitleListPanel({ export default SubtitleListPanel +interface SubtitleSearchResultsPanelProps { + results: SubtitleSearchResult[] + query: string + onSelect: (subtitleIndex: number) => void + currentIndex: number + formatTime: (time: number) => string +} + +function SubtitleSearchResultsPanel({ + results, + query, + onSelect, + currentIndex, + formatTime +}: SubtitleSearchResultsPanelProps) { + const { t } = useTranslation() + + if (results.length === 0) { + return ( + + {t('player.subtitleList.search.emptyTitle')} + {t('player.subtitleList.search.emptySubtitle')} + + ) + } + + return ( + + {results.map(({ subtitle, index }) => ( + onSelect(index)} + > + + {formatTime(subtitle.startTime)} + {formatTime(subtitle.endTime)} + + + + + + ))} + + ) +} + const Container = styled.div` position: relative; flex: 1; @@ -237,6 +432,42 @@ const ScrollContainer = styled.div` } ` +const SearchResultsList = styled.div` + display: flex; + flex-direction: column; + padding: 4px 0; + width: 100%; +` + +const SearchResultItem = styled.div<{ $active: boolean }>` + display: block; + margin: 6px 8px; + padding: 10px 12px; + cursor: pointer; + border-radius: 12px; + background: ${(p) => (p.$active ? 'var(--color-primary-mute)' : 'transparent')}; + box-shadow: ${(p) => (p.$active ? '0 1px 6px rgba(0,0,0,.25)' : 'none')}; + transition: background 0.2s; + + &:hover { + background: ${(p) => + p.$active ? 'var(--color-primary-mute)' : 'var(--color-list-item-hover)'}; + } +` + +const SearchResultsEmpty = styled.div` + min-height: 100%; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 24px; + color: var(--color-text-3, #666); + text-align: center; +` + const EmptyState = styled.div` flex: 1 1 auto; min-height: 0; @@ -320,3 +551,158 @@ const TextContent = styled.div` line-height: 1.5; word-break: break-word; ` + +// 搜索相关样式组件 +const ControlBar = styled.div` + display: flex; + align-items: center; + justify-content: stretch; + min-height: 40px; + padding: ${SPACING.XS}px ${SPACING.SM}px; + background: var(--color-background-soft, rgba(255, 255, 255, 0.02)); + border-bottom: 1px solid var(--color-border-soft, rgba(255, 255, 255, 0.06)); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + flex-shrink: 0; + transition: all 0.2s ease; + animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +` + +const SearchInputContainer = styled.div` + display: flex; + align-items: center; + flex: 1; + gap: ${SPACING.XXS}px; + height: 28px; + padding: 0 ${SPACING.XS}px; + background: var(--color-fill-2, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--color-border-soft, rgba(255, 255, 255, 0.08)); + border-radius: ${BORDER_RADIUS.LG}px; + animation: slideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: all 0.2s ease; + + &:focus-within { + background: var(--color-fill-1, rgba(255, 255, 255, 0.06)); + border-color: var(--color-primary, #1890ff); + box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1); + } + + @keyframes slideIn { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } + } +` + +const SearchIconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-3, #666); + + .spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +` + +const SearchInput = styled.input` + flex: 1; + height: 100%; + border: none; + outline: none; + background: transparent; + color: var(--color-text-1, #ddd); + font-size: 12px; + padding: 0; + min-width: 0; + + &::placeholder { + color: var(--color-text-3, #666); + } +` + +const ClearButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-text-3, #666); + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: var(--color-fill-3, rgba(255, 255, 255, 0.1)); + color: var(--color-text-1, #ddd); + } + + &:active { + transform: scale(0.9); + } +` + +const CloseButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: ${BORDER_RADIUS.SM}px; + background: transparent; + color: var(--color-text-3, #666); + cursor: pointer; + flex-shrink: 0; + margin-left: ${SPACING.XXS}px; + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: var(--color-fill-3, rgba(255, 255, 255, 0.08)); + color: var(--color-text-1, #ddd); + } + + &:active { + transform: scale(0.96); + } +` + +const SearchResultsHeader = styled.div` + padding: ${SPACING.XXS}px ${SPACING.SM}px; + font-size: 11px; + font-weight: 500; + color: var(--color-text-3, #666); + background: var(--color-background-soft, rgba(255, 255, 255, 0.02)); + border-bottom: 1px solid var(--color-border-soft, rgba(255, 255, 255, 0.04)); + flex-shrink: 0; + letter-spacing: 0.2px; +` diff --git a/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx b/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx new file mode 100644 index 00000000..e050019c --- /dev/null +++ b/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx @@ -0,0 +1,57 @@ +import { FC, memo } from 'react' +import styled from 'styled-components' + +interface HighlightedTextProps { + text: string + query: string +} + +/** + * 高亮显示文本中匹配的关键词 + * @param text 原始文本 + * @param query 搜索关键词 + */ +const HighlightedText: FC = memo(({ text, query }) => { + if (!query.trim()) { + return <>{text} + } + + try { + // 创建正则表达式,匹配所有关键词(不区分大小写) + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') + const parts = text.split(regex) + + return ( + <> + {parts.map((part, index) => { + // 检查是否为匹配的关键词 + if (part.toLowerCase() === query.toLowerCase()) { + return {part} + } + return {part} + })} + + ) + } catch (error) { + // 如果正则表达式出错,返回原始文本 + return <>{text} + } +}) + +HighlightedText.displayName = 'HighlightedText' + +export default HighlightedText + +const Highlight = styled.mark` + background-color: var(--ant-color-warning-bg, #fffbe6); + color: var(--ant-color-text, #000000); + padding: 2px 0; + border-radius: 2px; + font-weight: 500; + + /* 深色主题下的高亮样式 */ + [theme-mode='dark'] & { + background-color: rgba(250, 173, 20, 0.25); + color: var(--ant-color-warning, #faad14); + } +` diff --git a/src/renderer/src/state/stores/player-ui.store.ts b/src/renderer/src/state/stores/player-ui.store.ts index 22a28374..0079876f 100644 --- a/src/renderer/src/state/stores/player-ui.store.ts +++ b/src/renderer/src/state/stores/player-ui.store.ts @@ -11,6 +11,9 @@ export interface PlayerUIState { overlays: { settingsOpen: boolean } + subtitleSearch: { + isSearchVisible: boolean + } } export interface PlayerUIActions { @@ -21,6 +24,9 @@ export interface PlayerUIActions { setLoading: (loading: boolean) => void openSettings: () => void closeSettings: () => void + toggleSubtitleSearch: () => void + showSubtitleSearch: () => void + hideSubtitleSearch: () => void } export type PlayerUIStore = PlayerUIState & PlayerUIActions @@ -34,6 +40,9 @@ const initialState: PlayerUIState = { isLoading: false, overlays: { settingsOpen: false + }, + subtitleSearch: { + isSearchVisible: false } } @@ -86,6 +95,24 @@ const createPlayerUIStore: StateCreator< set((s: Draft) => { s.overlays.settingsOpen = false }) + }, + + toggleSubtitleSearch: () => { + set((s: Draft) => { + s.subtitleSearch.isSearchVisible = !s.subtitleSearch.isSearchVisible + }) + }, + + showSubtitleSearch: () => { + set((s: Draft) => { + s.subtitleSearch.isSearchVisible = true + }) + }, + + hideSubtitleSearch: () => { + set((s: Draft) => { + s.subtitleSearch.isSearchVisible = false + }) } }) From 32e43b5efb1b262c322ff14b7358c299bd9d9cd4 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 15 Oct 2025 19:15:35 +0800 Subject: [PATCH 2/2] chore: use spacing tokens for subtitle highlight --- .../src/pages/player/components/SubtitleSearchHighlight.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx b/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx index e050019c..d3f13c45 100644 --- a/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx +++ b/src/renderer/src/pages/player/components/SubtitleSearchHighlight.tsx @@ -1,3 +1,4 @@ +import { SPACING } from '@renderer/infrastructure/styles/theme' import { FC, memo } from 'react' import styled from 'styled-components' @@ -45,8 +46,8 @@ export default HighlightedText const Highlight = styled.mark` background-color: var(--ant-color-warning-bg, #fffbe6); color: var(--ant-color-text, #000000); - padding: 2px 0; - border-radius: 2px; + padding: ${SPACING.XXS / 2}px 0; + border-radius: ${SPACING.XXS / 2}px; font-weight: 500; /* 深色主题下的高亮样式 */