feat: enhance translation functionality with partial segment handling and UI updates

This commit is contained in:
lml committed 2026-03-21 22:02:03 +08:00
1 parent cae8b6d774
commit 31adb05ec8
10 files changed
+318 -115

No files matched your search

+15 -9
View File
@@ -1,4 +1,3 @@
import 'package:caption_trans/models/subtitle_segment.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../services/translation/translation_provider.dart';
import '../../services/translation/translation_service.dart';
@@ -21,11 +20,14 @@ class TranslationBloc extends Bloc<TranslationEvent, TranslationState> {
StartTranslation event,
Emitter<TranslationState> emit,
) async {
var latestPartials = event.segments;
try {
emit(
TranslationInProgress(
completed: 0,
total: event.segments.length,
partialSegments: latestPartials,
statusMessage: 'Initializing translation...',
),
);
@@ -38,6 +40,7 @@ class TranslationBloc extends Bloc<TranslationEvent, TranslationState> {
segments: event.segments,
config: event.config,
onProgress: (completed, total, partials) {
latestPartials = partials;
if (!emit.isDone) {
emit(
TranslationInProgress(
@@ -59,19 +62,24 @@ class TranslationBloc extends Bloc<TranslationEvent, TranslationState> {
);
} on TranslationAbortedException catch (e) {
if (!emit.isDone) {
List<SubtitleSegment>? latestPartials;
if (state is TranslationInProgress) {
latestPartials = (state as TranslationInProgress).partialSegments;
}
emit(
TranslationCancelled(
message: e.message,
partialSegments: latestPartials ?? event.segments,
partialSegments: latestPartials,
config: event.config,
),
);
}
} catch (e) {
emit(TranslationError(message: e.toString()));
if (!emit.isDone) {
emit(
TranslationError(
message: e.toString(),
partialSegments: latestPartials,
config: event.config,
),
);
}
}
}
@@ -80,9 +88,7 @@ class TranslationBloc extends Bloc<TranslationEvent, TranslationState> {
Emitter<TranslationState> emit,
) {
if (state is TranslationInProgress) {
final s = state as TranslationInProgress;
_translationService.cancel();
emit(TranslationCancelled(partialSegments: s.partialSegments));
}
}
+11 -3
View File
@@ -53,22 +53,30 @@ class TranslationComplete extends TranslationState {
class TranslationCancelled extends TranslationState {
final String message;
final List<SubtitleSegment>? partialSegments;
final TranslationConfig? config;
const TranslationCancelled({
this.message = 'Translation cancelled',
this.partialSegments,
this.config,
});
@override
List<Object?> get props => [message, partialSegments];
List<Object?> get props => [message, partialSegments, config];
}
/// Translation failed.
class TranslationError extends TranslationState {
final String message;
final List<SubtitleSegment>? partialSegments;
final TranslationConfig? config;
const TranslationError({required this.message});
const TranslationError({
required this.message,
this.partialSegments,
this.config,
});
@override
List<Object?> get props => [message];
List<Object?> get props => [message, partialSegments, config];
}
+2
View File
@@ -62,6 +62,8 @@
"enterGeminiModel": "e.g., gemini-2.0-flash, gpt-4o",
"targetLanguage": "Target Language",
"translate": "Translate",
"continueTranslation": "Continue Translation",
"retranslate": "Retranslate",
"initializingTranslation": "Initializing translation...",
"translatingProgress": "Translating subtitles ({completed}/{total})...",
"@translatingProgress": {
+12
View File
@@ -410,6 +410,18 @@ abstract class AppLocalizations {
/// **'Translate'**
String get translate;
/// No description provided for @continueTranslation.
///
/// In en, this message translates to:
/// **'Continue Translation'**
String get continueTranslation;
/// No description provided for @retranslate.
///
/// In en, this message translates to:
/// **'Retranslate'**
String get retranslate;
/// No description provided for @initializingTranslation.
///
/// In en, this message translates to:
+6
View File
@@ -170,6 +170,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get translate => 'Translate';
@override
String get continueTranslation => 'Continue Translation';
@override
String get retranslate => 'Retranslate';
@override
String get initializingTranslation => 'Initializing translation...';
+6
View File
@@ -166,6 +166,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get translate => '翻译';
@override
String get continueTranslation => '继续翻译';
@override
String get retranslate => '重新翻译';
@override
String get initializingTranslation => '正在初始化翻译...';
+3 -1
View File
@@ -52,6 +52,8 @@
"enterGeminiModel": "例如:gemini-2.0-flash, gpt-4o",
"targetLanguage": "目标语言",
"translate": "翻译",
"continueTranslation": "继续翻译",
"retranslate": "重新翻译",
"initializingTranslation": "正在初始化翻译...",
"translatingProgress": "正在翻译字幕 ({completed}/{total})...",
"segmentsTranslated": "已翻译 {count} 条字幕为{lang}",
@@ -81,4 +83,4 @@
"savedProvidersTitle": "已保存的服务商",
"savedProvidersEmpty": "暂无已保存的服务商",
"savedProvidersHint": "API验证成功后自会动保存到本地"
}
}
+6 -2
View File
@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart';
const Object _translatedTextNotSet = Object();
/// A single subtitle segment with timing and text content.
class SubtitleSegment extends Equatable {
final int index;
@@ -21,14 +23,16 @@ class SubtitleSegment extends Equatable {
Duration? startTime,
Duration? endTime,
String? text,
String? translatedText,
Object? translatedText = _translatedTextNotSet,
}) {
return SubtitleSegment(
index: index ?? this.index,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
text: text ?? this.text,
translatedText: translatedText ?? this.translatedText,
translatedText: identical(translatedText, _translatedTextNotSet)
? this.translatedText
: translatedText as String?,
);
}
+196 -91
View File
@@ -195,6 +195,118 @@ class _HomePageState extends State<HomePage> {
}
}
TranslationConfig? _buildCurrentTranslationConfig(
TranscriptionState transcriptionState,
) {
if (transcriptionState is! TranscriptionComplete) {
return null;
}
return TranslationConfig(
providerId: _llmProvider,
apiKey: _apiKey,
baseUrl: _llmBaseUrl,
model: _targetModel,
sourceLanguage: transcriptionState.result.language,
targetLanguage: _targetLanguage,
batchSize: _batchSize,
);
}
List<SubtitleSegment>? _resolveEffectiveSegments(
TranslationState translationState,
TranscriptionState transcriptionState,
) {
if (translationState is TranslationInProgress &&
translationState.partialSegments != null) {
return translationState.partialSegments;
}
if (translationState is TranslationComplete) {
return translationState.translatedSegments;
}
if (translationState is TranslationCancelled &&
translationState.partialSegments != null) {
return translationState.partialSegments;
}
if (translationState is TranslationError &&
translationState.partialSegments != null) {
return translationState.partialSegments;
}
if (transcriptionState is TranscriptionComplete) {
return transcriptionState.result.segments;
}
return null;
}
int _countTranslatedSegments(List<SubtitleSegment>? segments) {
if (segments == null) {
return 0;
}
return segments
.where((segment) => segment.translatedText?.trim().isNotEmpty == true)
.length;
}
List<SubtitleSegment> _clearTranslatedSegments(
List<SubtitleSegment> segments,
) {
return segments
.map((segment) => segment.copyWith(translatedText: null))
.toList();
}
void _persistProjectTranslation(
BuildContext context, {
required List<SubtitleSegment> segments,
TranslationConfig? config,
}) {
if (_activeProject == null) {
return;
}
final updatedResult = _activeProject!.transcription.copyWith(
segments: segments,
);
final updatedProject = _activeProject!.copyWith(
transcription: updatedResult,
updatedAt: DateTime.now(),
translationConfig: config ?? _activeProject!.translationConfig,
);
_activeProject = updatedProject;
context.read<ProjectBloc>().add(UpdateProject(updatedProject));
}
void _startTranslation(
BuildContext context, {
required TranslationState translationState,
required TranscriptionState transcriptionState,
bool restart = false,
}) {
final config = _buildCurrentTranslationConfig(transcriptionState);
final segments = _resolveEffectiveSegments(
translationState,
transcriptionState,
);
if (config == null || segments == null) {
return;
}
context.read<TranslationBloc>().add(
StartTranslation(
segments: restart ? _clearTranslatedSegments(segments) : segments,
config: config,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -271,6 +383,13 @@ class _HomePageState extends State<HomePage> {
const SizedBox(height: 12),
BlocConsumer<TranslationBloc, TranslationState>(
listener: (context, state) {
final transcriptionState = context
.read<TranscriptionBloc>()
.state;
final currentConfig = _buildCurrentTranslationConfig(
transcriptionState,
);
if (state is TranslationError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -278,40 +397,58 @@ class _HomePageState extends State<HomePage> {
backgroundColor: Colors.red.shade700,
),
);
if (state.partialSegments != null) {
_persistProjectTranslation(
context,
segments: state.partialSegments!,
config: state.config ?? currentConfig,
);
}
} else if (state is TranslationInProgress &&
state.partialSegments != null) {
if (_activeProject != null) {
final updatedResult = _activeProject!.transcription
.copyWith(segments: state.partialSegments!);
_activeProject = _activeProject!.copyWith(
transcription: updatedResult,
updatedAt: DateTime.now(),
);
context.read<ProjectBloc>().add(
UpdateProject(_activeProject!),
);
}
_persistProjectTranslation(
context,
segments: state.partialSegments!,
config: currentConfig,
);
} else if (state is TranslationComplete) {
if (_activeProject != null) {
final updatedResult = _activeProject!.transcription
.copyWith(segments: state.translatedSegments);
_activeProject = _activeProject!.copyWith(
transcription: updatedResult,
updatedAt: DateTime.now(),
translationConfig: state.config,
);
context.read<ProjectBloc>().add(
UpdateProject(_activeProject!),
);
}
_persistProjectTranslation(
context,
segments: state.translatedSegments,
config: state.config,
);
} else if (state is TranslationCancelled &&
state.partialSegments != null) {
_persistProjectTranslation(
context,
segments: state.partialSegments!,
config: state.config ?? currentConfig,
);
}
},
builder: (context, translationState) {
return BlocBuilder<TranscriptionBloc, TranscriptionState>(
builder: (context, transcriptionState) {
final effectiveSegments = _resolveEffectiveSegments(
translationState,
transcriptionState,
);
final translatedSegmentCount =
_countTranslatedSegments(effectiveSegments);
final totalSegmentCount =
effectiveSegments?.length ?? 0;
final hasPartialTranslation =
translatedSegmentCount > 0 &&
translatedSegmentCount < totalSegmentCount;
final isFullyTranslated =
totalSegmentCount > 0 &&
translatedSegmentCount == totalSegmentCount;
return TranslationPanel(
transcriptionState: transcriptionState,
translationState: translationState,
translatedSegmentCount: translatedSegmentCount,
totalSegmentCount: totalSegmentCount,
llmProvider: _llmProvider,
llmBaseUrl: _llmBaseUrl,
onLlmProviderChanged: (provider) {
@@ -347,25 +484,23 @@ class _HomePageState extends State<HomePage> {
widget.settingsService.setBatchSize(size);
},
onStartTranslation: () {
if (transcriptionState is TranscriptionComplete) {
context.read<TranslationBloc>().add(
StartTranslation(
segments:
transcriptionState.result.segments,
config: TranslationConfig(
providerId: _llmProvider,
apiKey: _apiKey,
baseUrl: _llmBaseUrl,
model: _targetModel,
sourceLanguage:
transcriptionState.result.language,
targetLanguage: _targetLanguage,
batchSize: _batchSize,
),
),
);
}
_startTranslation(
context,
translationState: translationState,
transcriptionState: transcriptionState,
);
},
onRestartTranslation:
hasPartialTranslation || isFullyTranslated
? () {
_startTranslation(
context,
translationState: translationState,
transcriptionState: transcriptionState,
restart: true,
);
}
: null,
onCancelTranslation: () {
context.read<TranslationBloc>().add(
const CancelTranslation(),
@@ -511,63 +646,28 @@ class _HomePageState extends State<HomePage> {
builder: (context, translationState) {
return BlocBuilder<TranscriptionBloc, TranscriptionState>(
builder: (context, transcriptionState) {
List<SubtitleSegment>? segments;
bool hasTranslation = false;
if (translationState is TranslationInProgress &&
translationState.partialSegments != null) {
segments = translationState.partialSegments;
hasTranslation =
segments != null &&
segments.any((s) => s.translatedText?.isNotEmpty == true);
} else if (translationState is TranslationComplete) {
segments = translationState.translatedSegments;
hasTranslation = true;
} else if (translationState is TranslationCancelled) {
segments = translationState.partialSegments;
hasTranslation =
segments != null &&
segments.any((s) => s.translatedText?.isNotEmpty == true);
} else if (transcriptionState is TranscriptionComplete) {
segments = transcriptionState.result.segments;
// Check if the loaded project naturally has translations
hasTranslation = segments.any(
(s) => s.translatedText?.isNotEmpty == true,
);
}
final segments = _resolveEffectiveSegments(
translationState,
transcriptionState,
);
final hasTranslation = _countTranslatedSegments(segments) > 0;
return SubtitlePreview(
segments: segments,
hasTranslation:
hasTranslation || translationState is TranslationComplete,
hasTranslation: hasTranslation,
bilingual: _bilingual,
onBilingualChanged: (v) {
setState(() => _bilingual = v);
widget.settingsService.setBilingual(v);
},
onExportOriginal: segments != null
? () => _exportSrt(
context,
segments!.cast<dynamic>(),
false,
false,
)
? () => _exportSrt(context, segments, false, false)
: null,
onExportTranslated: hasTranslation && segments != null
? () => _exportSrt(
context,
segments!.cast<dynamic>(),
true,
false,
)
? () => _exportSrt(context, segments, true, false)
: null,
onExportBilingual: hasTranslation && segments != null
? () => _exportSrt(
context,
segments!.cast<dynamic>(),
false,
true,
)
? () => _exportSrt(context, segments, false, true)
: null,
);
},
@@ -604,14 +704,14 @@ class _HomePageState extends State<HomePage> {
Future<void> _exportSrt(
BuildContext context,
List<dynamic> segments,
List<SubtitleSegment> segments,
bool translatedOnly,
bool bilingual,
) async {
final l10n = AppLocalizations.of(context)!;
try {
final srtContent = SrtParser.generate(
segments.cast(),
segments,
useTranslation: translatedOnly,
bilingual: bilingual,
);
@@ -864,10 +964,15 @@ class _HomePageState extends State<HomePage> {
_activeProject = project;
_sourceVideoLanguage = project.sourceVideoLanguage;
if (project.translationConfig != null) {
_targetLanguage = project.translationConfig!.targetLanguage;
// Restore other config if needed
if (project.translationConfig!.model != null) {
_targetModel = project.translationConfig!.model!;
final config = project.translationConfig!;
_llmProvider = config.providerId;
_llmBaseUrl = config.baseUrl;
_apiKey = config.apiKey;
_targetLanguage = config.targetLanguage;
_batchSize = config.batchSize;
_availableModels = config.model != null ? [config.model!] : [];
if (config.model != null) {
_targetModel = config.model!;
}
}
});
+61 -9
View File
@@ -23,6 +23,8 @@ const Map<String, String> defaultLlmBaseUrls = {
class TranslationPanel extends StatelessWidget {
final TranscriptionState transcriptionState;
final TranslationState translationState;
final int translatedSegmentCount;
final int totalSegmentCount;
final String targetLanguage;
final String llmProvider;
final String llmBaseUrl;
@@ -39,12 +41,15 @@ class TranslationPanel extends StatelessWidget {
final ValueChanged<int> onBatchSizeChanged;
final VoidCallback onCheckModels;
final VoidCallback onStartTranslation;
final VoidCallback? onRestartTranslation;
final VoidCallback onCancelTranslation;
const TranslationPanel({
super.key,
required this.transcriptionState,
required this.translationState,
required this.translatedSegmentCount,
required this.totalSegmentCount,
required this.targetLanguage,
required this.llmProvider,
required this.llmBaseUrl,
@@ -61,6 +66,7 @@ class TranslationPanel extends StatelessWidget {
required this.onBatchSizeChanged,
required this.onCheckModels,
required this.onStartTranslation,
this.onRestartTranslation,
required this.onCancelTranslation,
});
@@ -318,10 +324,9 @@ class TranslationPanel extends StatelessWidget {
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
LayoutBuilder(
builder: (context, constraints) {
final controls = Expanded(
child: DropdownButtonFormField<String>(
initialValue: targetLanguage,
decoration: const InputDecoration(
@@ -348,10 +353,28 @@ class TranslationPanel extends StatelessWidget {
if (v != null) onTargetLanguageChanged(v);
},
),
),
const SizedBox(width: 16),
_buildStartButton(context, l10n),
],
);
final actions = _buildActionButtons(context, l10n);
final useStackedLayout =
constraints.maxWidth < 720 &&
(_hasPartialTranslation || _isFullyTranslated);
if (useStackedLayout) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [controls]),
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [controls, const SizedBox(width: 16), actions],
);
},
),
if (_isTranslating ||
translationState is TranslationComplete ||
@@ -368,6 +391,11 @@ class TranslationPanel extends StatelessWidget {
}
bool get _isTranslating => translationState is TranslationInProgress;
bool get _hasAnyTranslation => translatedSegmentCount > 0;
bool get _hasPartialTranslation =>
_hasAnyTranslation && translatedSegmentCount < totalSegmentCount;
bool get _isFullyTranslated =>
totalSegmentCount > 0 && translatedSegmentCount == totalSegmentCount;
bool get _canStart =>
transcriptionState is TranscriptionComplete &&
@@ -386,7 +414,7 @@ class TranslationPanel extends StatelessWidget {
return '${model.substring(0, head)}...${model.substring(model.length - tail)}';
}
Widget _buildStartButton(BuildContext context, AppLocalizations l10n) {
Widget _buildActionButtons(BuildContext context, AppLocalizations l10n) {
if (_isTranslating) {
final errorColor = Theme.of(context).colorScheme.error;
return OutlinedButton.icon(
@@ -400,6 +428,30 @@ class TranslationPanel extends StatelessWidget {
);
}
if (_hasPartialTranslation) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton(
onPressed: _canStart ? onStartTranslation : null,
child: Text(l10n.continueTranslation),
),
OutlinedButton(
onPressed: _canStart ? onRestartTranslation : null,
child: Text(l10n.retranslate),
),
],
);
}
if (_isFullyTranslated) {
return OutlinedButton(
onPressed: _canStart ? onRestartTranslation : null,
child: Text(l10n.retranslate),
);
}
return FilledButton(
onPressed: _canStart ? onStartTranslation : null,
child: Text(l10n.translate),