Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 106 additions & 0 deletions db/migrations/20261018225119_add_favorite_subtitles_field.js
Original file line number Diff line number Diff line change
@@ -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 }
15 changes: 15 additions & 0 deletions openspec/changes/add-subtitle-favorites/proposal.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions openspec/changes/add-subtitle-favorites/tasks.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/shared/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export interface PlayerSettingsTable {
playbackRate: number
/** 收藏的播放速度 JSON 数组字符串 */
favoriteRates: string
/** 收藏的字幕台词 JSON 数组字符串 */
favoriteSubtitles: string
/** 音量 (0-1) */
volume: number
/** 是否静音 */
Expand Down
3 changes: 3 additions & 0 deletions src/main/db/schemas/player-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/locales/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/locales/ja-jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
"none": "一致する字幕が見つかりません",
"pending": "検索中...",
"placeholder": "字幕を検索..."
},
"tabs": {
"all": "字幕",
"favorites": "お気に入り",
"label": "字幕パネル"
},
"favorites": {
"emptySubtitle": "字幕横の星をクリックすると復習用に保存できます",
"emptyTitle": "保存した台詞はまだありません",
"favorite": "この台詞を保存",
"unfavorite": "保存を解除"
}
},
"subtitleTrackSelector": {
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/locales/ru-ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@
"none": "Субтитры по запросу не найдены",
"pending": "Поиск...",
"placeholder": "Поиск субтитров..."
},
"tabs": {
"all": "Субтитры",
"favorites": "Избранное",
"label": "Панель субтитров"
},
"favorites": {
"emptySubtitle": "Нажмите звезду рядом со строкой, чтобы сохранить её для повторения",
"emptyTitle": "Пока нет сохранённых строк",
"favorite": "Сохранить эту строку",
"unfavorite": "Убрать из избранного"
}
},
"subtitleTrackSelector": {
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/locales/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,17 @@
"none": "未找到匹配的字幕",
"pending": "搜索中...",
"placeholder": "搜索字幕..."
},
"tabs": {
"all": "字幕流",
"favorites": "收藏台词",
"label": "字幕面板"
},
"favorites": {
"emptySubtitle": "点击字幕旁的星号即可收藏,方便复习",
"emptyTitle": "还没有收藏的台词",
"favorite": "收藏这句字幕",
"unfavorite": "取消收藏"
}
},
"subtitleTrackSelector": {
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/locales/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
"none": "未找到符合的字幕",
"pending": "搜尋中...",
"placeholder": "搜尋字幕..."
},
"tabs": {
"all": "字幕流",
"favorites": "收藏台詞",
"label": "字幕面板"
},
"favorites": {
"emptySubtitle": "點擊字幕旁的星號即可收藏,方便複習",
"emptyTitle": "還沒有收藏的台詞",
"favorite": "收藏這句字幕",
"unfavorite": "取消收藏"
}
},
"subtitleTrackSelector": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/infrastructure/types/subtitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading