From 452ff580086d0023e9cbf6f4892831f6a02edd95 Mon Sep 17 00:00:00 2001 From: linzetong Date: Sun, 18 Oct 2026 23:09:05 +0800 Subject: [PATCH] feat(player): add subtitle favorites with review tab Allow starring subtitle cues for per-video review, with seek-back from a dedicated star tab in the subtitle panel. Co-authored-by: Cursor --- ...1018225119_add_favorite_subtitles_field.js | 106 ++++ .../add-subtitle-favorites/proposal.md | 15 + .../specs/subtitle-favorites/spec.md | 50 ++ .../changes/add-subtitle-favorites/tasks.md | 21 + packages/shared/schema.ts | 2 + src/main/db/schemas/player-settings.ts | 3 + src/renderer/src/i18n/locales/en-us.json | 11 + src/renderer/src/i18n/locales/ja-jp.json | 11 + src/renderer/src/i18n/locales/ru-ru.json | 11 + src/renderer/src/i18n/locales/zh-cn.json | 11 + src/renderer/src/i18n/locales/zh-tw.json | 11 + .../src/infrastructure/types/subtitle.ts | 8 + .../player/components/SubtitleListPanel.tsx | 544 +++++++++++++----- .../src/services/PlayerSettingsLoader.ts | 5 + src/renderer/src/state/stores/player.store.ts | 90 ++- 15 files changed, 750 insertions(+), 149 deletions(-) create mode 100644 db/migrations/20261018225119_add_favorite_subtitles_field.js create mode 100644 openspec/changes/add-subtitle-favorites/proposal.md create mode 100644 openspec/changes/add-subtitle-favorites/specs/subtitle-favorites/spec.md create mode 100644 openspec/changes/add-subtitle-favorites/tasks.md diff --git a/db/migrations/20261018225119_add_favorite_subtitles_field.js b/db/migrations/20261018225119_add_favorite_subtitles_field.js new file mode 100644 index 00000000..20ffd489 --- /dev/null +++ b/db/migrations/20261018225119_add_favorite_subtitles_field.js @@ -0,0 +1,106 @@ +/** + * Migration: Add favoriteSubtitles field to player settings + * + * Adds the following field to the playerSettings table: + * - favoriteSubtitles: JSON string containing array of favorited subtitle cue snapshots + * + * Each snapshot stores { id, startTime, endTime, originalText } so favorites remain + * displayable and seekable even if subtitle file identity shifts slightly. + */ +async function up(db) { + await db.schema + .alterTable('playerSettings') + .addColumn('favoriteSubtitles', 'text', (col) => col.notNull().defaultTo(JSON.stringify([]))) + .execute() + + console.log('✅ Added favoriteSubtitles field to playerSettings table') +} + +/** + * Rollback: Remove favoriteSubtitles field from player settings + */ +async function down(db) { + const { sql } = require('kysely') + + await db.schema + .createTable('playerSettings_temp') + .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement()) + .addColumn('videoId', 'integer', (col) => + col.notNull().references('videoLibrary.id').onDelete('cascade') + ) + .addColumn('playbackRate', 'real', (col) => col.notNull().defaultTo(1.0)) + .addColumn('favoriteRates', 'text', (col) => + col.notNull().defaultTo(JSON.stringify([0.75, 1.0, 1.25, 1.5])) + ) + .addColumn('volume', 'real', (col) => col.notNull().defaultTo(1.0)) + .addColumn('muted', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('loopSettings', 'text') + .addColumn('autoPauseSettings', 'text') + .addColumn('subtitleOverlaySettings', 'text') + .addColumn('created_at', 'integer', (col) => col.notNull().defaultTo(sql`(unixepoch())`)) + .addColumn('updated_at', 'integer', (col) => col.notNull().defaultTo(sql`(unixepoch())`)) + .execute() + + await db + .insertInto('playerSettings_temp') + .columns([ + 'id', + 'videoId', + 'playbackRate', + 'favoriteRates', + 'volume', + 'muted', + 'loopSettings', + 'autoPauseSettings', + 'subtitleOverlaySettings', + 'created_at', + 'updated_at' + ]) + .expression( + db + .selectFrom('playerSettings') + .select([ + 'id', + 'videoId', + 'playbackRate', + 'favoriteRates', + 'volume', + 'muted', + 'loopSettings', + 'autoPauseSettings', + 'subtitleOverlaySettings', + 'created_at', + 'updated_at' + ]) + ) + .execute() + + await db.schema.dropTable('playerSettings').execute() + await db.schema.alterTable('playerSettings_temp').renameTo('playerSettings').execute() + + await db.schema + .createIndex('idx_playerSettings_videoId') + .ifNotExists() + .on('playerSettings') + .column('videoId') + .execute() + + await db.schema + .createIndex('idx_playerSettings_updated_at') + .ifNotExists() + .on('playerSettings') + .column('updated_at') + .execute() + + await db.schema + .createIndex('idx_playerSettings_videoId_unique') + .ifNotExists() + .on('playerSettings') + .column('videoId') + .unique() + .execute() + + console.log('✅ Removed favoriteSubtitles field from playerSettings table') +} + +module.exports = { up, down } diff --git a/openspec/changes/add-subtitle-favorites/proposal.md b/openspec/changes/add-subtitle-favorites/proposal.md new file mode 100644 index 00000000..19f76060 --- /dev/null +++ b/openspec/changes/add-subtitle-favorites/proposal.md @@ -0,0 +1,15 @@ +## Why + +Language learners often need to bookmark specific subtitle lines for later review. EchoPlayer already supports click-to-seek in the subtitle list, but there is no per-cue favorite/bookmark flow or dedicated review surface. + +## What Changes + +- Add a star control on each subtitle cue in the subtitle stream to toggle favorite state +- Add a vertical star tab on the left edge of the subtitle panel listing favorited cues +- Clicking a favorited cue seeks to that line’s position for situational review +- Persist favorited cues per video in `playerSettings.favoriteSubtitles` + +## Impact + +- Affected specs: `subtitle-favorites` (new capability) +- Affected code: `SubtitleListPanel`, `player.store`, `PlayerSettingsLoader/Saver`, DB schema/migration, i18n diff --git a/openspec/changes/add-subtitle-favorites/specs/subtitle-favorites/spec.md b/openspec/changes/add-subtitle-favorites/specs/subtitle-favorites/spec.md new file mode 100644 index 00000000..1792fabc --- /dev/null +++ b/openspec/changes/add-subtitle-favorites/specs/subtitle-favorites/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Favorite Subtitle Cue + +The system SHALL allow the user to favorite and unfavorite an individual subtitle cue from the subtitle stream by clicking a star control on that cue. Favoriting MUST NOT trigger seek by itself. + +#### Scenario: Toggle favorite on cue + +- **WHEN** the user clicks the star control on a subtitle cue +- **THEN** that cue is added to or removed from the video’s favorite list +- **AND** the star control reflects the updated favorite state +- **AND** playback position is not changed solely by the star click + +### Requirement: Favorites Review Tab + +The system SHALL provide a star tab in the subtitle panel that lists favorited cues for the current video so the user can review them. + +#### Scenario: View favorited cues + +- **WHEN** the user opens the star tab in the subtitle panel +- **THEN** the panel shows the favorited cues for the current video +- **AND** each entry displays timing and original text from the saved snapshot + +#### Scenario: Empty favorites + +- **WHEN** the user opens the star tab and no cues are favorited +- **THEN** the panel shows an empty state explaining that favorites will appear here + +### Requirement: Seek From Favorite Cue + +The system SHALL seek to the corresponding subtitle position when the user clicks a favorited cue, enabling situational review. + +#### Scenario: Jump to matching cue + +- **WHEN** the user clicks a favorited cue that still matches a cue in the current subtitle list +- **THEN** the player seeks to that cue via subtitle-index seek + +#### Scenario: Fallback seek by time + +- **WHEN** the user clicks a favorited cue that cannot be matched in the current subtitle list +- **THEN** the player seeks to the saved `startTime` of the favorite + +### Requirement: Per-Video Persistence + +The system SHALL persist favorited cues per video across sessions. + +#### Scenario: Restore favorites on reopen + +- **WHEN** the user reopens a video that previously had favorited cues +- **THEN** those favorites are restored in the player state and star tab diff --git a/openspec/changes/add-subtitle-favorites/tasks.md b/openspec/changes/add-subtitle-favorites/tasks.md new file mode 100644 index 00000000..69a3f3a5 --- /dev/null +++ b/openspec/changes/add-subtitle-favorites/tasks.md @@ -0,0 +1,21 @@ +## 1. Persistence + +- [x] 1.1 Add `favoriteSubtitles` column migration on `playerSettings` +- [x] 1.2 Update shared schema and Zod player-settings schemas +- [x] 1.3 Wire Loader/Saver JSON map for `favoriteSubtitles` + +## 2. State + +- [x] 2.1 Add `FavoriteSubtitle` type and `favoriteSubtitles` to player store +- [x] 2.2 Implement `toggleFavoriteSubtitle` / `removeFavoriteSubtitle` +- [x] 2.3 Include `favoriteSubtitles` in persisted `PlayerSettings` slice and `loadSettings` + +## 3. UI + +- [x] 3.1 Add left vertical tabs (all / favorites) in subtitle panel +- [x] 3.2 Add star button on subtitle rows with stopPropagation +- [x] 3.3 Render favorites list with seek-on-click (index resolve + time fallback) + +## 4. i18n + +- [x] 4.1 Add favorites-related strings to locale files diff --git a/packages/shared/schema.ts b/packages/shared/schema.ts index 297f5557..8ab64bed 100644 --- a/packages/shared/schema.ts +++ b/packages/shared/schema.ts @@ -80,6 +80,8 @@ export interface PlayerSettingsTable { playbackRate: number /** 收藏的播放速度 JSON 数组字符串 */ favoriteRates: string + /** 收藏的字幕台词 JSON 数组字符串 */ + favoriteSubtitles: string /** 音量 (0-1) */ volume: number /** 是否静音 */ diff --git a/src/main/db/schemas/player-settings.ts b/src/main/db/schemas/player-settings.ts index 1096d954..5e6a68e5 100644 --- a/src/main/db/schemas/player-settings.ts +++ b/src/main/db/schemas/player-settings.ts @@ -27,6 +27,7 @@ export const PlayerSettingsInsertSchema = z.object({ videoId: PositiveIntegerSchema, playbackRate: PlaybackRateSchema.default(1.0), favoriteRates: JsonStringSchema.default(JSON.stringify([])), + favoriteSubtitles: JsonStringSchema.default(JSON.stringify([])), volume: VolumeSchema.default(1.0), muted: BooleanToSqlSchema.default(false), loopSettings: JsonStringSchema.optional(), @@ -40,6 +41,7 @@ export const PlayerSettingsInsertSchema = z.object({ export const PlayerSettingsUpdateSchema = z.object({ playbackRate: PlaybackRateSchema.optional(), favoriteRates: JsonStringSchema.optional(), + favoriteSubtitles: JsonStringSchema.optional(), volume: VolumeSchema.optional(), muted: BooleanToSqlSchema.optional(), loopSettings: JsonStringSchema.optional(), @@ -57,6 +59,7 @@ export const PlayerSettingsSelectSchema = z.object({ videoId: PositiveIntegerSchema, playbackRate: PlaybackRateSchema, favoriteRates: JsonStringSchema, + favoriteSubtitles: JsonStringSchema, volume: VolumeSchema, muted: SqlBooleanSchema, loopSettings: z.string().nullable(), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b8850941..24dd1547 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -163,6 +163,17 @@ "none": "No subtitles match your search", "pending": "Searching...", "placeholder": "Search subtitles..." + }, + "tabs": { + "all": "Subtitles", + "favorites": "Saved lines", + "label": "Subtitle panel" + }, + "favorites": { + "emptySubtitle": "Click the star next to a line to save it for review", + "emptyTitle": "No saved lines yet", + "favorite": "Save this line", + "unfavorite": "Remove from saved" } }, "subtitleTrackSelector": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b4c47ddc..9d4d95e8 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -36,6 +36,17 @@ "none": "一致する字幕が見つかりません", "pending": "検索中...", "placeholder": "字幕を検索..." + }, + "tabs": { + "all": "字幕", + "favorites": "お気に入り", + "label": "字幕パネル" + }, + "favorites": { + "emptySubtitle": "字幕横の星をクリックすると復習用に保存できます", + "emptyTitle": "保存した台詞はまだありません", + "favorite": "この台詞を保存", + "unfavorite": "保存を解除" } }, "subtitleTrackSelector": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ee097711..a34437b2 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -35,6 +35,17 @@ "none": "Субтитры по запросу не найдены", "pending": "Поиск...", "placeholder": "Поиск субтитров..." + }, + "tabs": { + "all": "Субтитры", + "favorites": "Избранное", + "label": "Панель субтитров" + }, + "favorites": { + "emptySubtitle": "Нажмите звезду рядом со строкой, чтобы сохранить её для повторения", + "emptyTitle": "Пока нет сохранённых строк", + "favorite": "Сохранить эту строку", + "unfavorite": "Убрать из избранного" } }, "subtitleTrackSelector": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a3138f57..64d706b2 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -285,6 +285,17 @@ "none": "未找到匹配的字幕", "pending": "搜索中...", "placeholder": "搜索字幕..." + }, + "tabs": { + "all": "字幕流", + "favorites": "收藏台词", + "label": "字幕面板" + }, + "favorites": { + "emptySubtitle": "点击字幕旁的星号即可收藏,方便复习", + "emptyTitle": "还没有收藏的台词", + "favorite": "收藏这句字幕", + "unfavorite": "取消收藏" } }, "subtitleTrackSelector": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index bd349c89..66865793 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -33,6 +33,17 @@ "none": "未找到符合的字幕", "pending": "搜尋中...", "placeholder": "搜尋字幕..." + }, + "tabs": { + "all": "字幕流", + "favorites": "收藏台詞", + "label": "字幕面板" + }, + "favorites": { + "emptySubtitle": "點擊字幕旁的星號即可收藏,方便複習", + "emptyTitle": "還沒有收藏的台詞", + "favorite": "收藏這句字幕", + "unfavorite": "取消收藏" } }, "subtitleTrackSelector": { diff --git a/src/renderer/src/infrastructure/types/subtitle.ts b/src/renderer/src/infrastructure/types/subtitle.ts index e14b51a4..9cc245b5 100644 --- a/src/renderer/src/infrastructure/types/subtitle.ts +++ b/src/renderer/src/infrastructure/types/subtitle.ts @@ -25,6 +25,14 @@ export interface SubtitleItem { readonly words?: WordTimestamp[] } +/** 收藏字幕快照(按视频持久化) */ +export interface FavoriteSubtitle { + readonly id: string + readonly startTime: number + readonly endTime: number + readonly originalText: string +} + // 字幕格式枚举 / Subtitle Format Enum export enum SubtitleFormat { SRT = 'srt', diff --git a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx index 65c095d9..c5a711e1 100644 --- a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx +++ b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx @@ -1,8 +1,13 @@ import { BORDER_RADIUS, SPACING } from '@renderer/infrastructure/styles/theme' +import { + isSameFavoriteSubtitle, + resolveFavoriteSubtitleIndex, + usePlayerStore +} from '@renderer/state/stores/player.store' import { usePlayerUIStore } from '@renderer/state/stores/player-ui.store' -import type { SubtitleItem } from '@types' -import { Button } from 'antd' -import { FileText, Loader2, Search, Sparkles, Video, X } from 'lucide-react' +import type { FavoriteSubtitle, SubtitleItem } from '@types' +import { Button, Tooltip } from 'antd' +import { FileText, List, Loader2, Search, Sparkles, Star, Video, 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' @@ -18,6 +23,8 @@ import { useSubtitles } from '../state/player-context' import { ImportSubtitleButton } from './' import HighlightedText from './SubtitleSearchHighlight' +type SubtitlePanelTab = 'all' | 'favorites' + interface EmptyAction { key?: string label: string @@ -69,7 +76,6 @@ function SubtitleListPanel({ asrEnabled = false }: SubtitleListPannelProps) { const subtitles = useSubtitles() - usePlayerEngine() const virtuosoRef = useRef(null) const scrollerElRef = useRef(null) const avgItemHeightRef = useRef(56) // 初始估算单项高度 @@ -81,15 +87,47 @@ function SubtitleListPanel({ const { seekToSubtitle } = usePlayerCommands() const { t } = useTranslation() + const favoriteSubtitles = usePlayerStore((s) => s.favoriteSubtitles) + const toggleFavoriteSubtitle = usePlayerStore((s) => s.toggleFavoriteSubtitle) + // 从 store 读取搜索状态 const isSearchVisible = usePlayerUIStore((s) => s.subtitleSearch.isSearchVisible) const hideSubtitleSearch = usePlayerUIStore((s) => s.hideSubtitleSearch) - // 搜索相关状态 + // 面板 Tab / 搜索相关状态 + const [activeTab, setActiveTab] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [isSearching, setIsSearching] = useState(false) const searchInputRef = useRef(null) + const isFavoriteCue = useCallback( + (subtitle: SubtitleItem) => { + return favoriteSubtitles.some((fav) => isSameFavoriteSubtitle(fav, subtitle)) + }, + [favoriteSubtitles] + ) + + const handleToggleFavorite = useCallback( + (e: React.MouseEvent, subtitle: SubtitleItem) => { + e.stopPropagation() + e.preventDefault() + toggleFavoriteSubtitle(subtitle) + }, + [toggleFavoriteSubtitle] + ) + + const handleFavoriteClick = useCallback( + (favorite: FavoriteSubtitle) => { + const index = resolveFavoriteSubtitleIndex(favorite, subtitles) + if (index >= 0) { + seekToSubtitle(index) + return + } + orchestrator?.requestUserSeek(favorite.startTime) + }, + [subtitles, seekToSubtitle, orchestrator] + ) + const normalizedQuery = searchQuery.trim() const searchResults = useMemo(() => { @@ -292,132 +330,195 @@ function SubtitleListPanel({ ) } + const showAllTab = activeTab === 'all' || isSearchVisible || hasSearchQuery + return ( - {/* Control Bar - 搜索控制栏(仅在搜索激活时显示)*/} - {isSearchVisible && ( - - - - {isSearching ? : } - - - {searchQuery && ( - - - - )} - - - - - - )} - - {/* 搜索结果提示 */} - {hasSearchQuery && ( - - {isSearching ? ( - {t('player.subtitleList.search.pending')} - ) : searchResults.length > 0 ? ( - {t('player.subtitleList.search.count', { count: searchResults.length })} + + + + setActiveTab('all')} + > + + + + + setActiveTab('favorites')} + > + 0 ? 'currentColor' : 'none'} /> + + + + + + {showAllTab ? ( + <> + {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')} + )} + + )} + + + {hasSearchQuery ? ( + + ) : ( + { + const favorited = isFavoriteCue(subtitle) + return ( + handleItemClick(index)} + > + + {formatTime(subtitle.startTime)} + {formatTime(subtitle.endTime)} + handleToggleFavorite(e, subtitle)} + > + + + + {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 }) + }} + /> + )} + + ) : ( - {t('player.subtitleList.search.none')} + )} - - )} - - - {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 }) - }} - /> - )} - + + ) } @@ -430,6 +531,8 @@ interface SubtitleSearchResultsPanelProps { onSelect: (subtitleIndex: number) => void currentIndex: number formatTime: (time: number) => string + isFavoriteCue: (subtitle: SubtitleItem) => boolean + onToggleFavorite: (e: React.MouseEvent, subtitle: SubtitleItem) => void } function SubtitleSearchResultsPanel({ @@ -437,7 +540,9 @@ function SubtitleSearchResultsPanel({ query, onSelect, currentIndex, - formatTime + formatTime, + isFavoriteCue, + onToggleFavorite }: SubtitleSearchResultsPanelProps) { const { t } = useTranslation() @@ -452,25 +557,104 @@ function SubtitleSearchResultsPanel({ return ( - {results.map(({ subtitle, index }) => ( - onSelect(index)} - > - - {formatTime(subtitle.startTime)} - {formatTime(subtitle.endTime)} - - - - - - ))} + {results.map(({ subtitle, index }) => { + const favorited = isFavoriteCue(subtitle) + return ( + onSelect(index)} + > + + {formatTime(subtitle.startTime)} + {formatTime(subtitle.endTime)} + onToggleFavorite(e, subtitle)} + > + + + + + + + + ) + })} + + ) +} + +interface FavoriteSubtitlesPanelProps { + favorites: FavoriteSubtitle[] + currentIndex: number + subtitles: SubtitleItem[] + formatTime: (time: number) => string + onSelect: (favorite: FavoriteSubtitle) => void + onToggleFavorite: (e: React.MouseEvent, subtitle: SubtitleItem) => void +} + +function FavoriteSubtitlesPanel({ + favorites, + currentIndex, + subtitles, + formatTime, + onSelect, + onToggleFavorite +}: FavoriteSubtitlesPanelProps) { + const { t } = useTranslation() + + if (favorites.length === 0) { + return ( + + + {t('player.subtitleList.favorites.emptyTitle')} + {t('player.subtitleList.favorites.emptySubtitle')} + + ) + } + + return ( + + {favorites.map((favorite) => { + const matchedIndex = resolveFavoriteSubtitleIndex(favorite, subtitles) + const active = matchedIndex >= 0 && matchedIndex === currentIndex + return ( + onSelect(favorite)} + > + + {formatTime(favorite.startTime)} + {formatTime(favorite.endTime)} + onToggleFavorite(e, favorite)} + > + + + + {favorite.originalText} + + ) + })} ) } @@ -486,6 +670,50 @@ const Container = styled.div` flex-direction: column; ` +const PanelLayout = styled.div` + display: flex; + flex: 1; + min-height: 0; + height: 100%; +` + +const SideTabs = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: ${SPACING.XXS}px; + width: 40px; + flex-shrink: 0; + padding: ${SPACING.SM}px ${SPACING.XXS}px; + border-right: 1px solid var(--color-border-soft, rgba(255, 255, 255, 0.06)); +` + +const SideTabButton = styled.button<{ $active: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: ${BORDER_RADIUS.SM}px; + cursor: pointer; + color: ${(p) => (p.$active ? 'var(--ant-color-primary, #1890ff)' : 'var(--color-text-3, #888)')}; + background: ${(p) => + p.$active ? 'var(--color-primary-mute, rgba(24, 144, 255, 0.12))' : 'transparent'}; + + &:hover { + background: var(--color-list-item-hover, rgba(255, 255, 255, 0.06)); + } +` + +const PanelBody = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +` + const ScrollContainer = styled.div` flex: 1 1 auto; min-height: 0; @@ -662,7 +890,7 @@ const ActionButton = styled(Button)` border-radius: ${BORDER_RADIUS.LG}px; ` -const SubtitleItem = styled.div<{ $active: boolean }>` +const SubtitleItemRow = styled.div<{ $active: boolean }>` display: block; margin: 6px 8px; padding: 10px 12px; @@ -678,6 +906,26 @@ const SubtitleItem = styled.div<{ $active: boolean }>` } ` +const FavoriteButton = styled.button<{ $active: boolean }>` + display: flex; + align-items: center; + justify-content: center; + margin-left: ${SPACING.XS}px; + width: 22px; + height: 22px; + border: none; + border-radius: ${BORDER_RADIUS.SM}px; + background: transparent; + cursor: pointer; + flex-shrink: 0; + color: ${(p) => (p.$active ? 'var(--ant-color-warning, #faad14)' : 'var(--color-text-3, #666)')}; + + &:hover { + background: var(--color-fill-3, rgba(255, 255, 255, 0.08)); + color: var(--ant-color-warning, #faad14); + } +` + const TimesRow = styled.div` display: flex; align-items: center; diff --git a/src/renderer/src/services/PlayerSettingsLoader.ts b/src/renderer/src/services/PlayerSettingsLoader.ts index 100698dd..dd0e55be 100644 --- a/src/renderer/src/services/PlayerSettingsLoader.ts +++ b/src/renderer/src/services/PlayerSettingsLoader.ts @@ -123,6 +123,9 @@ export class PlayerSettingsService { // 解析常用播放速度 const favoriteRates = parseJsonField(dbData.favoriteRates, globalSettings.defaultFavoriteRates) + // 解析收藏字幕 + const favoriteSubtitles = parseJsonField(dbData.favoriteSubtitles, []) + // 使用工厂函数创建 PlayerState,并用数据库数据覆盖 return createPlayerState({ globalSettings, @@ -132,6 +135,7 @@ export class PlayerSettingsService { playbackRate: dbData.playbackRate, favoriteRates, currentFavoriteIndex: Math.max(0, favoriteRates.indexOf(dbData.playbackRate)), + favoriteSubtitles, loopEnabled: loopSettings.loopEnabled, loopMode: loopSettings.loopMode, loopCount: loopSettings.loopCount, @@ -175,6 +179,7 @@ export class PlayerSettingsService { volume: state.volume, muted: state.muted, favoriteRates: JSON.stringify(state.favoriteRates), + favoriteSubtitles: JSON.stringify(state.favoriteSubtitles), // currentFavoriteIndex 是运行时状态,不需要持久化 loopSettings: JSON.stringify(loopSettings), autoPauseSettings: JSON.stringify(autoPauseSettings), diff --git a/src/renderer/src/state/stores/player.store.ts b/src/renderer/src/state/stores/player.store.ts index b88b25f9..c99d588b 100644 --- a/src/renderer/src/state/stores/player.store.ts +++ b/src/renderer/src/state/stores/player.store.ts @@ -1,10 +1,60 @@ import { PLAYBACK_RATE_PRESETS } from '@renderer/infrastructure' -import { LoopMode, SubtitleBackgroundType, SubtitleDisplayMode } from '@types' +import { + FavoriteSubtitle, + LoopMode, + SubtitleBackgroundType, + SubtitleDisplayMode, + SubtitleItem +} from '@types' import { Draft } from 'immer' import { create, StateCreator } from 'zustand' import { MiddlewarePresets } from '../infrastructure' +const FAVORITE_SUBTITLE_TIME_TOLERANCE = 0.05 + +/** 判断两条字幕是否为同一收藏项(优先 id + 时间,其次时间容差) */ +export function isSameFavoriteSubtitle( + a: Pick, + b: Pick +): boolean { + if ( + a.id && + b.id && + a.id === b.id && + Math.abs(a.startTime - b.startTime) <= FAVORITE_SUBTITLE_TIME_TOLERANCE + ) { + return true + } + return Math.abs(a.startTime - b.startTime) <= FAVORITE_SUBTITLE_TIME_TOLERANCE +} + +/** 在当前字幕列表中解析收藏项对应的 index;找不到返回 -1 */ +export function resolveFavoriteSubtitleIndex( + favorite: FavoriteSubtitle, + subtitles: readonly SubtitleItem[] +): number { + const byId = subtitles.findIndex( + (s) => + s.id === favorite.id && + Math.abs(s.startTime - favorite.startTime) <= FAVORITE_SUBTITLE_TIME_TOLERANCE + ) + if (byId >= 0) return byId + + return subtitles.findIndex( + (s) => Math.abs(s.startTime - favorite.startTime) <= FAVORITE_SUBTITLE_TIME_TOLERANCE + ) +} + +export function toFavoriteSubtitle(subtitle: SubtitleItem): FavoriteSubtitle { + return { + id: subtitle.id, + startTime: subtitle.startTime, + endTime: subtitle.endTime, + originalText: subtitle.originalText + } +} + /** * 字幕覆盖层配置接口 */ @@ -140,6 +190,9 @@ export function createPlayerState(options: PlayerStateOptions = {}): PlayerState return Math.max(0, rates.indexOf(speed)) })(), + // === 收藏字幕(可持久化) === + favoriteSubtitles: persistedSettings.favoriteSubtitles ?? [], + // === 循环设置(可持久化) === loopEnabled: persistedSettings.loopEnabled ?? false, loopMode: persistedSettings.loopMode ?? globalSettings.defaultLoopMode ?? LoopMode.SINGLE, @@ -240,6 +293,9 @@ export interface PlayerState { /** 当前常用速度索引(用于循环切换) */ currentFavoriteIndex: number + /** 收藏的字幕台词(当前视频独立保存) */ + favoriteSubtitles: FavoriteSubtitle[] + // === 转码状态管理 === /** 是否启用 HLS 播放模式 */ hlsMode: boolean @@ -319,6 +375,11 @@ export interface PlayerActions { cycleFavoriteRateNext: () => void // 切换到下一个常用速度 cycleFavoriteRatePrev: () => void // 切换到上一个常用速度 + // === 字幕收藏控制 === + toggleFavoriteSubtitle: (subtitle: SubtitleItem | FavoriteSubtitle) => void + removeFavoriteSubtitle: (favorite: FavoriteSubtitle) => void + setFavoriteSubtitles: (favorites: FavoriteSubtitle[]) => void + // === 循环控制 === // 组件可调用:用户设置 toggleLoopEnabled: () => void @@ -382,6 +443,7 @@ export type PlayerSettings = Pick< | 'muted' | 'playbackRate' | 'favoriteRates' + | 'favoriteSubtitles' | 'loopEnabled' | 'loopMode' | 'loopCount' @@ -498,6 +560,29 @@ const createPlayerStore: StateCreator + set((s: Draft) => { + s.favoriteSubtitles = favorites + }), + toggleFavoriteSubtitle: (subtitle) => + set((s: Draft) => { + const existingIndex = s.favoriteSubtitles.findIndex((fav) => + isSameFavoriteSubtitle(fav, subtitle) + ) + if (existingIndex >= 0) { + s.favoriteSubtitles.splice(existingIndex, 1) + } else { + s.favoriteSubtitles.push(toFavoriteSubtitle(subtitle as SubtitleItem)) + } + }), + removeFavoriteSubtitle: (favorite) => + set((s: Draft) => { + s.favoriteSubtitles = s.favoriteSubtitles.filter( + (fav) => !isSameFavoriteSubtitle(fav, favorite) + ) + }), + // 循环控制 toggleLoopEnabled: () => set((s: Draft) => { @@ -605,6 +690,9 @@ const createPlayerStore: StateCreator