diff --git a/assets/icon/unlock.svg b/assets/icon/unlock.svg index 71bf4849..93e22122 100644 --- a/assets/icon/unlock.svg +++ b/assets/icon/unlock.svg @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/lib/features/subtitle_editor/edit_text_sheet.dart b/lib/features/subtitle_editor/edit_text_sheet.dart new file mode 100644 index 00000000..149bf876 --- /dev/null +++ b/lib/features/subtitle_editor/edit_text_sheet.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../theme/app_theme.dart'; + +/// 显示编辑句子文本的底部面板。 +/// +/// 返回修改后的文本(已 trim),取消时返回 `null`。 +Future showEditTextSheet({ + required BuildContext context, + required int sentenceIndex, + required String initialText, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _EditTextSheet( + sentenceIndex: sentenceIndex, + initialText: initialText, + ), + ); +} + +class _EditTextSheet extends StatefulWidget { + final int sentenceIndex; + final String initialText; + + const _EditTextSheet({ + required this.sentenceIndex, + required this.initialText, + }); + + @override + State<_EditTextSheet> createState() => _EditTextSheetState(); +} + +class _EditTextSheetState extends State<_EditTextSheet> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + Navigator.pop(context, text); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.l, + AppSpacing.s, + AppSpacing.l, + AppSpacing.l, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 拖拽指示条 + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.m), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // 标题 + Text( + l10n.editSentenceTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.m), + + // 输入框 + TextField( + controller: _controller, + autofocus: true, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + height: 1.25, + ), + decoration: InputDecoration( + labelText: l10n.editSentenceLabel, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + labelStyle: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.72), + fontWeight: FontWeight.w500, + height: 1.2, + ), + floatingLabelStyle: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary.withValues(alpha: 0.78), + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() {}), + ), + + const SizedBox(height: AppSpacing.m), + + // 按钮行 + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: AppSpacing.s), + Expanded( + child: FilledButton( + onPressed: + _controller.text.trim().isEmpty ? null : _submit, + child: Text(l10n.save), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/subtitle_editor/subtitle_editor_controller.dart b/lib/features/subtitle_editor/subtitle_editor_controller.dart index 47c53f7c..5362a5bc 100644 --- a/lib/features/subtitle_editor/subtitle_editor_controller.dart +++ b/lib/features/subtitle_editor/subtitle_editor_controller.dart @@ -308,6 +308,12 @@ class SubtitleEditorController extends StateNotifier { return _sentenceWords(index); } + /// 当前编辑状态快照(供对话框等外部组件读取只读状态)。 + /// + /// 注意:`state` 是 StateNotifier 的 protected 成员,不能在 controller 之外的 + /// 实例中直接读取,这里暴露一个公开只读入口。 + SubtitleEditorState get snapshot => state; + /// 波形要绘制 / 可拖动的全部单词边界:选中句 + 前后相邻句的所有词。 /// /// 句子的起止边界即首词起点 / 末词终点,统一为单词边界(见 [adjustWord]),不再 @@ -697,6 +703,132 @@ class SubtitleEditorController extends StateNotifier { ); } + /// 替换第 [index] 句的全部文本为 [newText],保持原起止时间不变。 + /// + /// 新文本按字符数比例重建词级时间戳,首尾词贴合句界。句子数量不变, + /// 不会打乱索引对应的学习进度和收藏。 + void editSentenceText(int index, String newText) { + if (index < 0 || index >= state.sentences.length) return; + final trimmed = newText.trim(); + if (trimmed.isEmpty) return; + final sentence = state.sentences[index]; + + // 文本没变时不操作。 + if (trimmed == sentence.text) return; + + final newTokens = _splitTokens(trimmed); + if (newTokens.isEmpty) return; + + // 更新句子文本,起止时间不变。 + final updatedSentence = sentence.copyWith(text: trimmed); + + // 用字符比例重建词级时间戳。 + final newSentenceWords = _proportionalTokens(newTokens, updatedSentence); + newSentenceWords[0] = + newSentenceWords.first.copyWith(startTime: sentence.startTime); + newSentenceWords[newSentenceWords.length - 1] = + newSentenceWords.last.copyWith(endTime: sentence.endTime); + + // 替换全篇词列表中本句对应的区间。 + final range = _sentenceTokenRange(index); + final nextSentences = [...state.sentences]; + nextSentences[index] = updatedSentence; + + List nextWords; + if (range != null) { + nextWords = [ + ...state.words.sublist(0, range.offset), + ...newSentenceWords, + ...state.words.sublist(range.offset + range.count), + ]; + } else { + nextWords = _buildWords(nextSentences, state.words); + } + + final wasPlaying = state.isPlaying; + if (wasPlaying) _cancelPlaybackSession(); + state = state.copyWith( + sentences: nextSentences, + words: nextWords, + focusedWordIndex: null, + isDirty: _sentencesChanged(nextSentences) || _wordsDirty, + playingSentenceIndex: wasPlaying ? null : state.playingSentenceIndex, + isPlaying: wasPlaying ? false : state.isPlaying, + playbackMode: wasPlaying + ? SubtitleEditorPlaybackMode.idle + : state.playbackMode, + ); + } + + /// 调整第 [index] 句的起止时间戳。 + /// + /// [startTime] / [endTime] 为可选:仅传需调整的一端,未传的保持不变。 + /// 调整后按字符比例重建本句词级时间戳;句子数量不变,不打乱索引对应关系。 + void updateSentenceTimestamps( + int index, { + Duration? startTime, + Duration? endTime, + }) { + if (index < 0 || index >= state.sentences.length) return; + final sentence = state.sentences[index]; + + // 未提供任何调整则不操作。 + if (startTime == null && endTime == null) return; + + // 钳制到合法范围。 + final lower = _prevSentenceEnd(index); + final upper = _nextSentenceStart(index); + final newStart = _clampDuration(startTime ?? sentence.startTime, lower, upper); + final newEnd = _clampDuration(endTime ?? sentence.endTime, newStart + kMinWordDuration, upper); + + // 时间没变时不操作。 + if (newStart == sentence.startTime && newEnd == sentence.endTime) return; + + final updatedSentence = sentence.copyWith(startTime: newStart, endTime: newEnd); + + // 按字符比例重建词级时间戳。 + final tokens = _splitTokens(sentence.text); + final newSentenceWords = tokens.isEmpty + ? const [] + : _proportionalTokens(tokens, updatedSentence); + if (newSentenceWords.isNotEmpty) { + newSentenceWords[0] = + newSentenceWords.first.copyWith(startTime: newStart); + newSentenceWords[newSentenceWords.length - 1] = + newSentenceWords.last.copyWith(endTime: newEnd); + } + + final nextSentences = [...state.sentences]; + nextSentences[index] = updatedSentence; + + // 替换全篇词列表中本句对应的区间。 + final range = _sentenceTokenRange(index); + List nextWords; + if (range != null && tokens.isNotEmpty) { + nextWords = [ + ...state.words.sublist(0, range.offset), + ...newSentenceWords, + ...state.words.sublist(range.offset + range.count), + ]; + } else { + nextWords = _buildWords(nextSentences, state.words); + } + + final wasPlaying = state.isPlaying; + if (wasPlaying) _cancelPlaybackSession(); + state = state.copyWith( + sentences: nextSentences, + words: nextWords, + focusedWordIndex: null, + isDirty: _sentencesChanged(nextSentences) || _wordsDirty, + playingSentenceIndex: wasPlaying ? null : state.playingSentenceIndex, + isPlaying: wasPlaying ? false : state.isPlaying, + playbackMode: wasPlaying + ? SubtitleEditorPlaybackMode.idle + : state.playbackMode, + ); + } + /// 把选中句从第 [localWordIndex] 个词处分成两句(剪刀分句时调用)。 /// /// 该词成为新句(后半)的首词;前半保留原起点、终点贴前一词终点,后半起点贴该词 @@ -780,6 +912,40 @@ class SubtitleEditorController extends StateNotifier { } } + /// 播放指定时间区间 [start] 到 [end] 的音频片段。 + /// + /// 供时间戳编辑弹窗调用:点击起始/结束时间时播放对应端的音频。 + /// 播放期间不影响选中句和词聚焦态;播放完成后状态恢复 idle。 + Future playRange(Duration start, Duration end) async { + if (start >= end) return; + await _stopActivePlayback(invalidateSession: true); + final sessionId = _audioEngine.newSession(); + _startPlayheadTicker( + sessionId: sessionId, + start: start, + end: end, + ); + state = state.copyWith( + playingSentenceIndex: null, + isPlaying: true, + playbackMode: SubtitleEditorPlaybackMode.word, + playbackPosition: start, + ); + try { + await _audioEngine.setSpeed(state.playbackSpeed); + await _audioEngine.playRangeOnce(start, end, sessionId); + } finally { + if (mounted && _audioEngine.isActiveSession(sessionId)) { + state = state.copyWith( + isPlaying: false, + playbackMode: SubtitleEditorPlaybackMode.idle, + playbackPosition: end, + ); + await _stopActivePlayback(invalidateSession: false); + } + } + } + Future playSentence(int index) async { if (index < 0 || index >= state.sentences.length) return; await _stopActivePlayback(invalidateSession: true); diff --git a/lib/features/subtitle_editor/subtitle_simple_editor_screen.dart b/lib/features/subtitle_editor/subtitle_simple_editor_screen.dart index ae2abde9..37185e5b 100644 --- a/lib/features/subtitle_editor/subtitle_simple_editor_screen.dart +++ b/lib/features/subtitle_editor/subtitle_simple_editor_screen.dart @@ -13,8 +13,10 @@ import '../../models/word_timestamp.dart'; import '../../providers/new_user_guide_provider.dart'; import '../../theme/app_theme.dart'; import '../../widgets/guide_flow.dart'; +import 'edit_text_sheet.dart'; import 'subtitle_editor_controller.dart'; import 'subtitle_waveform_view.dart'; +import 'timestamp_editor_dialog.dart'; class SubtitleSimpleEditorScreen extends ConsumerStatefulWidget { final AudioItem audioItem; @@ -202,6 +204,10 @@ class _SubtitleSimpleEditorScreenState onMergeNext: controller.mergeWithNext, onDelete: (index) => _deleteSentence(context, controller, l10n, index), + onEditText: (index) => + _editSentenceText(context, controller, l10n, index), + onTimestamp: (index) => + _showTimestampEditor(context, controller, index), firstPlayGuideStep: sentencePlayStep, firstMenuGuideStep: sentenceMenuStep, ), @@ -275,6 +281,41 @@ class _SubtitleSimpleEditorScreenState ); } + /// 编辑句子文本:弹出文本输入对话框,修改后保存到控制器。 + void _editSentenceText( + BuildContext context, + SubtitleEditorController controller, + AppLocalizations l10n, + int index, + ) { + final sentences = controller.snapshot.sentences; + if (index < 0 || index >= sentences.length) return; + final currentText = sentences[index].text; + + showEditTextSheet( + context: context, + sentenceIndex: index, + initialText: currentText, + ).then((newText) { + if (newText != null && newText != currentText) { + controller.editSentenceText(index, newText); + } + }); + } + + /// 显示时间戳编辑对话框。 + void _showTimestampEditor( + BuildContext context, + SubtitleEditorController controller, + int index, + ) { + showTimestampEditor( + context: context, + controller: controller, + sentenceIndex: index, + ); + } + Future _confirmDiscard(BuildContext context, AppLocalizations l10n) { return showDialog( context: context, @@ -472,6 +513,8 @@ class _SentenceList extends StatefulWidget { final void Function(int wordIndex) onSplitWord; final void Function(int index) onMergeNext; final void Function(int index) onDelete; + final void Function(int index) onEditText; + final void Function(int index) onTimestamp; final GuideStep? firstPlayGuideStep; final GuideStep? firstMenuGuideStep; @@ -489,6 +532,8 @@ class _SentenceList extends StatefulWidget { required this.onSplitWord, required this.onMergeNext, required this.onDelete, + required this.onEditText, + required this.onTimestamp, this.firstPlayGuideStep, this.firstMenuGuideStep, }); @@ -633,6 +678,20 @@ class _SentenceListState extends State<_SentenceList> { color: theme.colorScheme.error, ), ), + PopupMenuItem( + value: _SentenceAction.timestamp, + child: _MenuRow( + icon: Icons.timer_outlined, + label: l10n.adjustTimestamp, + ), + ), + PopupMenuItem( + value: _SentenceAction.editText, + child: _MenuRow( + icon: Icons.edit, + label: l10n.editSentenceText, + ), + ), ], onSelected: (action) { switch (action) { @@ -640,6 +699,10 @@ class _SentenceListState extends State<_SentenceList> { widget.onMergeNext(index); case _SentenceAction.delete: widget.onDelete(index); + case _SentenceAction.editText: + widget.onEditText(index); + case _SentenceAction.timestamp: + widget.onTimestamp(index); } }, ), @@ -1162,4 +1225,4 @@ class _MenuRow extends StatelessWidget { } } -enum _SentenceAction { mergeNext, delete } +enum _SentenceAction { mergeNext, delete, editText, timestamp } diff --git a/lib/features/subtitle_editor/timestamp_editor_dialog.dart b/lib/features/subtitle_editor/timestamp_editor_dialog.dart new file mode 100644 index 00000000..7963f605 --- /dev/null +++ b/lib/features/subtitle_editor/timestamp_editor_dialog.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/sentence.dart'; +import '../../theme/app_theme.dart'; +import 'subtitle_editor_controller.dart'; + +/// 显示时间戳编辑对话框,用于调整单句字幕的起止时间。 +/// +/// 返回 `true` 表示用户保存了修改,`null` 或 `false` 表示取消。 +Future showTimestampEditor({ + required BuildContext context, + required SubtitleEditorController controller, + required int sentenceIndex, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _TimestampEditorSheet( + controller: controller, + sentenceIndex: sentenceIndex, + ), + ); +} + +class _TimestampEditorSheet extends StatefulWidget { + final SubtitleEditorController controller; + final int sentenceIndex; + + const _TimestampEditorSheet({ + required this.controller, + required this.sentenceIndex, + }); + + @override + State<_TimestampEditorSheet> createState() => _TimestampEditorSheetState(); +} + +class _TimestampEditorSheetState extends State<_TimestampEditorSheet> { + late int _currentIndex; + late Duration _startTime; + late Duration _endTime; + double _stepSeconds = 0.1; + bool _isPlayingStart = false; + bool _isPlayingEnd = false; + + SubtitleEditorController get _ctrl => widget.controller; + + @override + void initState() { + super.initState(); + _currentIndex = widget.sentenceIndex; + _loadSentenceTimes(); + } + + void _loadSentenceTimes() { + final sentences = _ctrl.snapshot.sentences; + if (_currentIndex < 0 || _currentIndex >= sentences.length) return; + final s = sentences[_currentIndex]; + _startTime = s.startTime; + _endTime = s.endTime; + } + + Sentence? get _currentSentence { + final sentences = _ctrl.snapshot.sentences; + if (_currentIndex < 0 || _currentIndex >= sentences.length) return null; + return sentences[_currentIndex]; + } + + bool get _hasPrevious => _currentIndex > 0; + bool get _hasNext => _currentIndex < _ctrl.snapshot.sentences.length - 1; + + void _adjustStart(Duration delta) { + final newStart = _startTime + delta; + if (newStart.isNegative || newStart >= _endTime) return; + setState(() => _startTime = newStart); + } + + void _adjustEnd(Duration delta) { + final newEnd = _endTime + delta; + if (newEnd <= _startTime) return; + final totalDuration = _ctrl.snapshot.totalDuration; + if (totalDuration != null && newEnd > totalDuration) return; + setState(() => _endTime = newEnd); + } + + Future _playStart() async { + if (_isPlayingStart || _isPlayingEnd) return; + setState(() => _isPlayingStart = true); + try { + final playEnd = _startTime + const Duration(seconds: 1); + final sentence = _currentSentence; + final actualEnd = sentence != null && playEnd > sentence.endTime + ? sentence.endTime + : playEnd; + await _ctrl.playRange(_startTime, actualEnd); + } finally { + if (mounted) setState(() => _isPlayingStart = false); + } + } + + Future _playEnd() async { + if (_isPlayingStart || _isPlayingEnd) return; + setState(() => _isPlayingEnd = true); + try { + final sentence = _currentSentence; + final playStart = _startTime > _endTime - const Duration(seconds: 1) + ? _startTime + : _endTime - const Duration(seconds: 1); + final actualStart = sentence != null && playStart < sentence.startTime + ? sentence.startTime + : playStart; + await _ctrl.playRange(actualStart, _endTime); + } finally { + if (mounted) setState(() => _isPlayingEnd = false); + } + } + + void _goToPrevious() { + if (!_hasPrevious) return; + _saveCurrent(false); + setState(() { + _currentIndex--; + _loadSentenceTimes(); + }); + } + + void _goToNext() { + if (!_hasNext) return; + _saveCurrent(false); + setState(() { + _currentIndex++; + _loadSentenceTimes(); + }); + } + + void _saveCurrent(bool dismiss) { + _ctrl.updateSentenceTimestamps( + _currentIndex, + startTime: _startTime, + endTime: _endTime, + ); + if (dismiss && context.mounted) { + Navigator.of(context).pop(true); + } + } + + String _formatDuration(Duration d) { + final seconds = d.inMilliseconds / 1000; + return seconds.toStringAsFixed(2); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final sentence = _currentSentence; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.l, + AppSpacing.s, + AppSpacing.l, + AppSpacing.l, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 拖拽指示条 + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.m), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // 标题 + Text( + l10n.timestampEditorTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + + // 当前句文本 + if (sentence != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.m), + child: Text( + sentence.text, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + // 起始 / 结束时间调节区 + Row( + children: [ + Expanded( + child: _TimeAdjuster( + label: l10n.timestampStart, + value: _formatDuration(_startTime), + isPlaying: _isPlayingStart, + onDecrease: () => _adjustStart( + Duration(milliseconds: (-_stepSeconds * 1000).round()), + ), + onIncrease: () => _adjustStart( + Duration(milliseconds: (_stepSeconds * 1000).round()), + ), + onPlay: _playStart, + ), + ), + const SizedBox(width: AppSpacing.m), + Expanded( + child: _TimeAdjuster( + label: l10n.timestampEnd, + value: _formatDuration(_endTime), + isPlaying: _isPlayingEnd, + onDecrease: () => _adjustEnd( + Duration(milliseconds: (-_stepSeconds * 1000).round()), + ), + onIncrease: () => _adjustEnd( + Duration(milliseconds: (_stepSeconds * 1000).round()), + ), + onPlay: _playEnd, + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.m), + + // 步长滑块 + Row( + children: [ + Text( + '${l10n.timestampStep}:', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + '${_stepSeconds.toStringAsFixed(1)}s', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + Expanded( + child: Slider( + value: _stepSeconds, + min: 0.1, + max: 3.0, + divisions: 29, + onChanged: (v) => setState(() => _stepSeconds = v), + ), + ), + ], + ), + + // 上一句 / 当前句 / 下一句 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _hasPrevious ? _goToPrevious : null, + child: Text(l10n.previousSentence), + ), + TextButton( + onPressed: () { + setState(() => _loadSentenceTimes()); + }, + child: Text(l10n.currentSentence), + ), + TextButton( + onPressed: _hasNext ? _goToNext : null, + child: Text(l10n.nextSentence), + ), + ], + ), + + const SizedBox(height: AppSpacing.s), + + // Save 按钮 + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => _saveCurrent(true), + child: Text(l10n.save), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 时间调节器:标签 + 数值 + 播放按钮 + 加减按钮。 +class _TimeAdjuster extends StatelessWidget { + final String label; + final String value; + final bool isPlaying; + final VoidCallback onDecrease; + final VoidCallback onIncrease; + final VoidCallback onPlay; + + const _TimeAdjuster({ + required this.label, + required this.value, + required this.isPlaying, + required this.onDecrease, + required this.onIncrease, + required this.onPlay, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Column( + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.xs), + GestureDetector( + onTap: isPlaying ? null : onPlay, + child: Text( + value, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: isPlaying ? colorScheme.primary : null, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: onDecrease, + icon: const Icon(Icons.remove_circle_outline), + iconSize: 28, + visualDensity: VisualDensity.compact, + ), + SizedBox( + width: 28, + height: 28, + child: IconButton( + onPressed: isPlaying ? null : onPlay, + icon: isPlaying + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + Icons.play_circle_outline, + color: colorScheme.primary, + ), + iconSize: 28, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + IconButton( + onPressed: onIncrease, + icon: const Icon(Icons.add_circle_outline), + iconSize: 28, + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2bc49c3f..6029278b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2183,6 +2183,15 @@ "editSubtitles": "Edit subtitles", "mergeWithNextSentence": "Merge with next", "deleteSentence": "Delete sentence", + "editSentenceText": "Edit Text", + "adjustTimestamp": "Timestamp", + "editSentenceTitle": "Edit sentence text", + "editSentenceLabel": "Sentence text", + "timestampEditorTitle": "Edit timestamp", + "timestampStart": "Start", + "timestampEnd": "End", + "timestampStep": "Step", + "currentSentence": "Current", "sentenceDeleted": "Sentence deleted", "playSentence": "Play sentence", "stopPlayback": "Stop playback", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a522b1c2..1254137c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7454,6 +7454,60 @@ abstract class AppLocalizations { /// **'Delete sentence'** String get deleteSentence; + /// No description provided for @editSentenceText. + /// + /// In en, this message translates to: + /// **'Edit Text'** + String get editSentenceText; + + /// No description provided for @adjustTimestamp. + /// + /// In en, this message translates to: + /// **'Timestamp'** + String get adjustTimestamp; + + /// No description provided for @editSentenceTitle. + /// + /// In en, this message translates to: + /// **'Edit sentence text'** + String get editSentenceTitle; + + /// No description provided for @editSentenceLabel. + /// + /// In en, this message translates to: + /// **'Sentence text'** + String get editSentenceLabel; + + /// No description provided for @timestampEditorTitle. + /// + /// In en, this message translates to: + /// **'Edit timestamp'** + String get timestampEditorTitle; + + /// No description provided for @timestampStart. + /// + /// In en, this message translates to: + /// **'Start'** + String get timestampStart; + + /// No description provided for @timestampEnd. + /// + /// In en, this message translates to: + /// **'End'** + String get timestampEnd; + + /// No description provided for @timestampStep. + /// + /// In en, this message translates to: + /// **'Step'** + String get timestampStep; + + /// No description provided for @currentSentence. + /// + /// In en, this message translates to: + /// **'Current'** + String get currentSentence; + /// No description provided for @sentenceDeleted. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 46e6f878..153e2e42 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4250,6 +4250,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteSentence => 'Delete sentence'; + @override + String get editSentenceText => 'Edit Text'; + + @override + String get adjustTimestamp => 'Timestamp'; + + @override + String get editSentenceTitle => 'Edit sentence text'; + + @override + String get editSentenceLabel => 'Sentence text'; + + @override + String get timestampEditorTitle => 'Edit timestamp'; + + @override + String get timestampStart => 'Start'; + + @override + String get timestampEnd => 'End'; + + @override + String get timestampStep => 'Step'; + + @override + String get currentSentence => 'Current'; + @override String get sentenceDeleted => 'Sentence deleted'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04b5d317..515de391 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4058,6 +4058,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deleteSentence => '删除句子'; + @override + String get editSentenceText => '编辑文本'; + + @override + String get adjustTimestamp => '时间戳'; + + @override + String get editSentenceTitle => '编辑句子文本'; + + @override + String get editSentenceLabel => '句子文本'; + + @override + String get timestampEditorTitle => '编辑时间戳'; + + @override + String get timestampStart => '起始'; + + @override + String get timestampEnd => '结束'; + + @override + String get timestampStep => '步长'; + + @override + String get currentSentence => '当前句'; + @override String get sentenceDeleted => '已删除句子'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 09c29e99..de39db0e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1706,6 +1706,15 @@ "editSubtitles": "编辑字幕", "mergeWithNextSentence": "合并下一句", "deleteSentence": "删除句子", + "editSentenceText": "编辑文本", + "adjustTimestamp": "时间戳", + "editSentenceTitle": "编辑句子文本", + "editSentenceLabel": "句子文本", + "timestampEditorTitle": "编辑时间戳", + "timestampStart": "起始", + "timestampEnd": "结束", + "timestampStep": "步长", + "currentSentence": "当前句", "sentenceDeleted": "已删除句子", "playSentence": "播放句子", "stopPlayback": "停止播放", diff --git a/test/features/subtitle_editor/subtitle_editor_controller_test.dart b/test/features/subtitle_editor/subtitle_editor_controller_test.dart index 328707c7..ae87bd43 100644 --- a/test/features/subtitle_editor/subtitle_editor_controller_test.dart +++ b/test/features/subtitle_editor/subtitle_editor_controller_test.dart @@ -1234,6 +1234,140 @@ void main() { ); }); }); + + group('editSentenceText', () { + test('替换句子文本并按比例重建词级时间戳', () async { + final notifier = controller(); + await notifier.load(); + + notifier.editSentenceText(0, 'Hello world'); + final s = state(); + + expect(s.sentences[0].text, 'Hello world'); + expect(s.sentences[0].startTime, sentences[0].startTime); + expect(s.sentences[0].endTime, sentences[0].endTime); + expect(s.isDirty, isTrue); + }); + + test('文本不变时不操作', () async { + final notifier = controller(); + await notifier.load(); + + notifier.editSentenceText(0, 'First sentence.'); + expect(state().isDirty, isFalse); + }); + + test('空文本不操作', () async { + final notifier = controller(); + await notifier.load(); + + notifier.editSentenceText(0, ' '); + expect(state().sentences[0].text, 'First sentence.'); + expect(state().isDirty, isFalse); + }); + + test('越界索引不操作', () async { + final notifier = controller(); + await notifier.load(); + + notifier.editSentenceText(5, 'Out of bounds'); + expect(state().isDirty, isFalse); + }); + }); + + group('updateSentenceTimestamps', () { + test('调整起始时间并重建词级时间戳', () async { + final notifier = controller(); + await notifier.load(); + + notifier.updateSentenceTimestamps( + 1, + startTime: const Duration(seconds: 5), + ); + final s = state(); + + expect(s.sentences[1].startTime, const Duration(seconds: 5)); + expect(s.sentences[1].endTime, sentences[1].endTime); + expect(s.isDirty, isTrue); + }); + + test('调整结束时间并重建词级时间戳', () async { + final notifier = controller(); + await notifier.load(); + + notifier.updateSentenceTimestamps( + 1, + endTime: const Duration(seconds: 7), + ); + final s = state(); + + expect(s.sentences[1].startTime, sentences[1].startTime); + expect(s.sentences[1].endTime, const Duration(seconds: 7)); + expect(s.isDirty, isTrue); + }); + + test('同时调整起止时间', () async { + final notifier = controller(); + await notifier.load(); + + notifier.updateSentenceTimestamps( + 1, + startTime: const Duration(seconds: 5), + endTime: const Duration(seconds: 7), + ); + final s = state(); + + expect(s.sentences[1].startTime, const Duration(seconds: 5)); + expect(s.sentences[1].endTime, const Duration(seconds: 7)); + }); + + test('钳制到前一句结束时间', () async { + final notifier = controller(); + await notifier.load(); + + // 第 1 句起始时间不能早于第 0 句结束(4s) + notifier.updateSentenceTimestamps( + 1, + startTime: const Duration(seconds: 2), + ); + expect(state().sentences[1].startTime, const Duration(seconds: 4)); + }); + + test('钳制到后一句起始时间', () async { + final notifier = controller(); + await notifier.load(); + + // 第 1 句结束时间不能晚于第 2 句起始(8s) + notifier.updateSentenceTimestamps( + 1, + endTime: const Duration(seconds: 10), + ); + expect(state().sentences[1].endTime, const Duration(seconds: 8)); + }); + + test('起止时间不变时不操作', () async { + final notifier = controller(); + await notifier.load(); + + notifier.updateSentenceTimestamps( + 1, + startTime: sentences[1].startTime, + endTime: sentences[1].endTime, + ); + expect(state().isDirty, isFalse); + }); + + test('越界索引不操作', () async { + final notifier = controller(); + await notifier.load(); + + notifier.updateSentenceTimestamps( + 5, + startTime: Duration.zero, + ); + expect(state().isDirty, isFalse); + }); + }); } /// 记录 loadAudio 的 forceTranscriptReload 入参,用于验证保存后是否强制重载 LP。