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
6 changes: 3 additions & 3 deletions assets/icon/unlock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
162 changes: 162 additions & 0 deletions lib/features/subtitle_editor/edit_text_sheet.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';

import '../../l10n/app_localizations.dart';
import '../../theme/app_theme.dart';

/// 显示编辑句子文本的底部面板。
///
/// 返回修改后的文本(已 trim),取消时返回 `null`。
Future<String?> showEditTextSheet({
required BuildContext context,
required int sentenceIndex,
required String initialText,
}) {
return showModalBottomSheet<String>(
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),
),
),
],
),
],
),
),
),
);
}
}
166 changes: 166 additions & 0 deletions lib/features/subtitle_editor/subtitle_editor_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ class SubtitleEditorController extends StateNotifier<SubtitleEditorState> {
return _sentenceWords(index);
}

/// 当前编辑状态快照(供对话框等外部组件读取只读状态)。
///
/// 注意:`state` 是 StateNotifier 的 protected 成员,不能在 controller 之外的
/// 实例中直接读取,这里暴露一个公开只读入口。
SubtitleEditorState get snapshot => state;

/// 波形要绘制 / 可拖动的全部单词边界:选中句 + 前后相邻句的所有词。
///
/// 句子的起止边界即首词起点 / 末词终点,统一为单词边界(见 [adjustWord]),不再
Expand Down Expand Up @@ -697,6 +703,132 @@ class SubtitleEditorController extends StateNotifier<SubtitleEditorState> {
);
}

/// 替换第 [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<WordTimestamp> 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 <WordTimestamp>[]
: _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<WordTimestamp> 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] 个词处分成两句(剪刀分句时调用)。
///
/// 该词成为新句(后半)的首词;前半保留原起点、终点贴前一词终点,后半起点贴该词
Expand Down Expand Up @@ -780,6 +912,40 @@ class SubtitleEditorController extends StateNotifier<SubtitleEditorState> {
}
}

/// 播放指定时间区间 [start] 到 [end] 的音频片段。
///
/// 供时间戳编辑弹窗调用:点击起始/结束时间时播放对应端的音频。
/// 播放期间不影响选中句和词聚焦态;播放完成后状态恢复 idle。
Future<void> 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<void> playSentence(int index) async {
if (index < 0 || index >= state.sentences.length) return;
await _stopActivePlayback(invalidateSession: true);
Expand Down
Loading