From 31adb05ec8c8698a9ae7c0b8d7eae1ffc8056512 Mon Sep 17 00:00:00 2001 From: lml Date: Sat, 21 Mar 2026 22:02:03 +0800 Subject: [PATCH] feat: enhance translation functionality with partial segment handling and UI updates --- lib/blocs/translation/translation_bloc.dart | 24 +- lib/blocs/translation/translation_state.dart | 14 +- lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 4 +- lib/models/subtitle_segment.dart | 8 +- lib/ui/home_page.dart | 287 +++++++++++++------ lib/ui/widgets/translation_panel.dart | 70 ++++- 10 files changed, 318 insertions(+), 115 deletions(-) diff --git a/lib/blocs/translation/translation_bloc.dart b/lib/blocs/translation/translation_bloc.dart index 58fe111..5c52809 100644 --- a/lib/blocs/translation/translation_bloc.dart +++ b/lib/blocs/translation/translation_bloc.dart @@ -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 { StartTranslation event, Emitter 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 { 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 { ); } on TranslationAbortedException catch (e) { if (!emit.isDone) { - List? 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 { Emitter emit, ) { if (state is TranslationInProgress) { - final s = state as TranslationInProgress; _translationService.cancel(); - emit(TranslationCancelled(partialSegments: s.partialSegments)); } } diff --git a/lib/blocs/translation/translation_state.dart b/lib/blocs/translation/translation_state.dart index 8d2c82e..1790ab8 100644 --- a/lib/blocs/translation/translation_state.dart +++ b/lib/blocs/translation/translation_state.dart @@ -53,22 +53,30 @@ class TranslationComplete extends TranslationState { class TranslationCancelled extends TranslationState { final String message; final List? partialSegments; + final TranslationConfig? config; const TranslationCancelled({ this.message = 'Translation cancelled', this.partialSegments, + this.config, }); @override - List get props => [message, partialSegments]; + List get props => [message, partialSegments, config]; } /// Translation failed. class TranslationError extends TranslationState { final String message; + final List? partialSegments; + final TranslationConfig? config; - const TranslationError({required this.message}); + const TranslationError({ + required this.message, + this.partialSegments, + this.config, + }); @override - List get props => [message]; + List get props => [message, partialSegments, config]; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 133579e..c3f73a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 50c1881..f013f46 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8a7406b..cea9e5d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 53fb0fc..df536ba 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -166,6 +166,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get translate => '翻译'; + @override + String get continueTranslation => '继续翻译'; + + @override + String get retranslate => '重新翻译'; + @override String get initializingTranslation => '正在初始化翻译...'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b2cb330..d6366fe 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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验证成功后自会动保存到本地" -} \ No newline at end of file +} diff --git a/lib/models/subtitle_segment.dart b/lib/models/subtitle_segment.dart index 586dd17..7d87fa4 100644 --- a/lib/models/subtitle_segment.dart +++ b/lib/models/subtitle_segment.dart @@ -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?, ); } diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 7d3bd54..bc0aff5 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -195,6 +195,118 @@ class _HomePageState extends State { } } + 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? _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? segments) { + if (segments == null) { + return 0; + } + + return segments + .where((segment) => segment.translatedText?.trim().isNotEmpty == true) + .length; + } + + List _clearTranslatedSegments( + List segments, + ) { + return segments + .map((segment) => segment.copyWith(translatedText: null)) + .toList(); + } + + void _persistProjectTranslation( + BuildContext context, { + required List 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().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().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 { const SizedBox(height: 12), BlocConsumer( listener: (context, state) { + final transcriptionState = context + .read() + .state; + final currentConfig = _buildCurrentTranslationConfig( + transcriptionState, + ); + if (state is TranslationError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -278,40 +397,58 @@ class _HomePageState extends State { 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().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().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( 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 { widget.settingsService.setBatchSize(size); }, onStartTranslation: () { - if (transcriptionState is TranscriptionComplete) { - context.read().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().add( const CancelTranslation(), @@ -511,63 +646,28 @@ class _HomePageState extends State { builder: (context, translationState) { return BlocBuilder( builder: (context, transcriptionState) { - List? 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(), - false, - false, - ) + ? () => _exportSrt(context, segments, false, false) : null, onExportTranslated: hasTranslation && segments != null - ? () => _exportSrt( - context, - segments!.cast(), - true, - false, - ) + ? () => _exportSrt(context, segments, true, false) : null, onExportBilingual: hasTranslation && segments != null - ? () => _exportSrt( - context, - segments!.cast(), - false, - true, - ) + ? () => _exportSrt(context, segments, false, true) : null, ); }, @@ -604,14 +704,14 @@ class _HomePageState extends State { Future _exportSrt( BuildContext context, - List segments, + List 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 { _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!; } } }); diff --git a/lib/ui/widgets/translation_panel.dart b/lib/ui/widgets/translation_panel.dart index 655a548..a12ea46 100644 --- a/lib/ui/widgets/translation_panel.dart +++ b/lib/ui/widgets/translation_panel.dart @@ -23,6 +23,8 @@ const Map 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 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( 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),