diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 35bd94b..e390eae 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -59,6 +59,7 @@ "(현재 방식은 옵션 없음)": "(no options for the built-in method)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)", "+ 여기에 새 줄": "+ New line here", + "- 감지한 언어": "- detected", "-1 은 아무렇게나": "-1 = random", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", @@ -442,6 +443,7 @@ "가져온 즐겨찾기": "Imported bookmarks", "가져온 즐겨찾기 ({0})": "Imported bookmarks ({0})", "가져올 즐겨찾기가 없습니다.": "No bookmarks to import.", + "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", @@ -641,6 +643,7 @@ "다른 앱에서 동영상을 열 때": "When opening a video from another app", "다른 앱에서 열기: {0}": "Opened from another app: {0}", "다른 앱으로 열기": "Open with another app", + "다른 언어로 다시": "Redo with another language", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.", "다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})", "다른 이름으로 저장": "Save as", @@ -1415,6 +1418,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", "원어: {0} ({1})": "Source: {0} ({1})", "원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)", + "원어를 골라 다시 만들기": "Choose the source language and redo", "원인: {0}": "Cause: {0}", "월": "Mon", "웹 브라우저": "Web browser", @@ -1849,6 +1853,7 @@ "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", "처음대로": "Reset to default", + "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "To set it from the start, choose it in \"Source language (spoken in the video)\" in the AI subtitles window.", "처음으로: {0}": "Back to the first: {0}", "첫 장입니다.": "This is the first page.", "첫 줄 추가": "Add first line", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2dea2f0..1da1d0a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -59,6 +59,7 @@ "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)", "+ 여기에 새 줄": "+ ここに新しい行", + "- 감지한 언어": "- 検出した言語", "-1 은 아무렇게나": "-1 はランダム", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", @@ -442,6 +443,7 @@ "가져온 즐겨찾기": "取り込んだブックマーク", "가져온 즐겨찾기 ({0})": "取り込んだブックマーク({0})", "가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。", + "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", @@ -641,6 +643,7 @@ "다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき", "다른 앱에서 열기: {0}": "他のアプリから開く: {0}", "다른 앱으로 열기": "別のアプリで開く", + "다른 언어로 다시": "別の言語でやり直す", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。", "다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)", "다른 이름으로 저장": "名前を付けて保存", @@ -1415,6 +1418,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", "원어: {0} ({1})": "原語: {0}({1})", "원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)", + "원어를 골라 다시 만들기": "原語を選んで作り直す", "원인: {0}": "原因: {0}", "월": "月", "웹 브라우저": "ウェブブラウザー", @@ -1849,6 +1853,7 @@ "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", "처음대로": "既定に戻す", + "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "最初から決めるには、AI 字幕ウィンドウの「原語(動画内の言語)」で選んでください。", "처음으로: {0}": "最初へ: {0}", "첫 장입니다.": "最初のページです。", "첫 줄 추가": "最初の行を追加", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6cc35a9..95429b2 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -59,6 +59,7 @@ "(현재 방식은 옵션 없음)": "(当前方式没有选项)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)", "+ 여기에 새 줄": "+ 在此新增一行", + "- 감지한 언어": "- 检测到的语言", "-1 은 아무렇게나": "-1 为随机", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", @@ -442,6 +443,7 @@ "가져온 즐겨찾기": "导入的书签", "가져온 즐겨찾기 ({0})": "导入的书签({0})", "가져올 즐겨찾기가 없습니다.": "没有可导入的书签。", + "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", @@ -641,6 +643,7 @@ "다른 앱에서 동영상을 열 때": "从其他应用打开视频时", "다른 앱에서 열기: {0}": "从其他应用打开: {0}", "다른 앱으로 열기": "用其他应用打开", + "다른 언어로 다시": "用其他语言重做", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。", "다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)", "다른 이름으로 저장": "另存为", @@ -1415,6 +1418,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", "원어: {0} ({1})": "原语言: {0}({1})", "원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)", + "원어를 골라 다시 만들기": "选择原语言并重新生成", "원인: {0}": "原因: {0}", "월": "一", "웹 브라우저": "网页浏览器", @@ -1849,6 +1853,7 @@ "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", "처음대로": "恢复默认", + "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "如需一开始就指定,请在 AI 字幕窗口的“原语言(视频中的语言)”中选择。", "처음으로: {0}": "回到第一个: {0}", "첫 장입니다.": "已是第一页。", "첫 줄 추가": "添加第一行", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 5b820cd..07ad65d 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -943,6 +943,8 @@ class AppController extends ChangeNotifier { ? opts.source : detectLanguage(cues.map((c) => c.text).join(' ')); _log(trf('음성인식 완료: {0}줄, 원어 {1}({2})', [cues.length, src.name, src.code])); + // 198: 자동 감지가 틀릴 수 있다 (영어를 한국어로 잡아 영어 발음을 한글로 적음) - 끝난 뒤 알리고 다른 언어로 다시 할 수 있게 + v.aiDetected = opts.source == undetermined ? src.code : null; // 5. 파일명_AI.srt (원본) await storage.ensureDirectory(outputDirFor(v.path)); diff --git a/lib/core/models.dart b/lib/core/models.dart index 607f603..22a0c92 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -99,6 +99,9 @@ class VideoItem { String? message; String? outputPath; + /// 198: 마지막 AI 자막에서 원어를 자동 감지했으면 그 언어 코드 (원어를 직접 골랐으면 null) + String? aiDetected; + VideoItem(this.path); String get fileName => p.basename(path); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index b629f8b..db11565 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -16,8 +16,16 @@ import '../l10n/tr.dart'; Future showAiDialog(BuildContext context, AppController c, VideoItem current, {List? targets, bool thenBuild = false}) async { final list = targets ?? [current]; - Future run(List v, AiOptions o) => - thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o); + // 198: 끝난 뒤 알림 · 다시 하기 창을 띄울 곳 (이 화면이 닫혀도 남는 것) + final messenger = ScaffoldMessenger.maybeOf(context); + final overlay = Navigator.maybeOf(context)?.overlay; + Future run(List v, AiOptions o) async { + await (thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o)); + if (o.source == undetermined && messenger != null) { + showDetectedLanguage(messenger, overlay, v, onRetry: (lang) => run(v, o.copyWith(source: lang))); + } + } + Future<(AiOptions, bool)?> full() => showDialog<(AiOptions, bool)>( context: context, builder: (_) => _AiDialog(c: c, videos: list, videoCount: targets == null ? c.videos.length : 1, thenBuild: thenBuild), @@ -43,6 +51,50 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre await run(all ? List.of(c.videos) : list, opts); } +/// 198: 원어를 자동 감지했으면 끝난 뒤 "감지한 언어: …" 와 [다른 언어로 다시] (언어 고르기 → 그 원어로 다시 만듦) +void showDetectedLanguage(ScaffoldMessengerState messenger, OverlayState? overlay, List videos, + {required Future Function(Language) onRetry}) { + final found = [for (final v in videos) if (v.aiDetected != null) (v, languageOf(v.aiDetected))]; + if (found.isEmpty) return; + final names = found.length == 1 + ? found.single.$2.name + : found.map((x) => '${x.$2.name} (${x.$1.fileName})').join(' · '); + messenger.showSnackBar(SnackBar( + duration: const Duration(seconds: 12), + content: Text(trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names])), + action: overlay == null + ? null + : SnackBarAction( + label: tr('다른 언어로 다시'), + onPressed: () async { + if (!overlay.mounted) return; + final lang = await pickSourceLanguage(overlay.context, found.first.$2); + if (lang != null) await onRetry(lang); + }, + ), + )); +} + +/// 198: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다) +Future pickSourceLanguage(BuildContext context, Language detected) => showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(tr('원어를 골라 다시 만들기')), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text(tr('처음부터 정하려면 AI 자막 창의 "원어 (영상 속 언어)" 에서 고르세요.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + for (final l in languages) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, l), + child: Text('${l.name} (${l.code})${l == detected ? ' ${tr('- 감지한 언어')}' : ''}'), + ), + ], + ), + ); + /// 138: "매번 묻기" 를 껐을 때 시작 전에 [취소] 할 수 있는 시간 const aiStartDelay = Duration(seconds: 3); diff --git a/test/ai_confirm_test.dart b/test/ai_confirm_test.dart index ae0a642..9c47598 100644 --- a/test/ai_confirm_test.dart +++ b/test/ai_confirm_test.dart @@ -79,4 +79,28 @@ void main() { await done; expect(c.busy, isFalse); }); + testWidgets('198: 원어를 자동 감지했으면 끝난 뒤 "감지한 언어" 와 [다른 언어로 다시] (고른 원어로 다시)', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold(body: SizedBox()))); + final v = VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko'; + Language? again; + showDetectedLanguage(tester.state(find.byType(ScaffoldMessenger)), key.currentState!.overlay, [v], + onRetry: (l) async => again = l); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 800)); // 알림이 다 올라올 때까지 + expect(find.text('감지한 언어: 한국어 - 틀렸으면 다른 언어로 다시 만드세요'), findsOneWidget); + await tester.tap(find.text('다른 언어로 다시')); + await tester.pumpAndSettle(); + expect(find.text('원어를 골라 다시 만들기'), findsOneWidget); + expect(find.textContaining('- 감지한 언어'), findsOneWidget); + await tester.tap(find.textContaining('영어 (en)')); + await tester.pumpAndSettle(); + expect(again?.code, 'en'); + // 원어를 직접 고른 동영상 (감지 안 함) 만 있으면 알리지 않는다 + final w = VideoItem(r'D:\v\b.mp4'); + showDetectedLanguage(tester.state(find.byType(ScaffoldMessenger)), key.currentState!.overlay, [w], + onRetry: (_) async {}); + await tester.pump(const Duration(seconds: 13)); + expect(find.textContaining('감지한 언어:'), findsNothing); + }); }