import 'dart:io'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/models.dart'; import '../services/model_store.dart'; import 'existing_output_dialog.dart'; import 'license_texts.dart' show NllbLicenseNote; import 'theme.dart'; import '../l10n/tr.dart'; /// AI 자막 만들기 설정 창 /// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택) /// [thenBuild]: 자막을 만든 뒤 그 동영상들을 이어서 MKV 로 만든다 Future showAiDialog(BuildContext context, AppController c, VideoItem current, {List? targets, bool thenBuild = false}) async { final list = targets ?? [current]; // 198: 끝난 뒤 알림 · 다시 하기 창을 띄울 곳 (이 화면이 닫혀도 남는 것) final messenger = ScaffoldMessenger.maybeOf(context); final overlay = Navigator.maybeOf(context)?.overlay; Future run(List v, AiOptions o) async { // P0: 자막 → MKV 면 결과 MKV 가 이미 있을 때 시작 전에 묻는다 String? existing; if (thenBuild) { if (!context.mounted) return; existing = await askExistingOutputs(context, c, v); if (existing == null) return; } await (thenBuild ? c.aiThenBuild(v, o, onExisting: existing) : 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), ); // 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로). // 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다. if (!c.settings.askAiOptions) { final messenger = ScaffoldMessenger.maybeOf(context); if (messenger == null) return run(list, c.aiOptions); var cancelled = false; messenger.showSnackBar(SnackBar( duration: aiStartDelay, content: Text(aiStartNotice(list, c.aiOptions, thenBuild: thenBuild)), action: SnackBarAction(label: tr('취소'), onPressed: () => cancelled = true), )); await Future.delayed(aiStartDelay); if (cancelled) return; return run(list, c.aiOptions); } final result = await full(); if (result == null) return; final (opts, all) = result; 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( // 248 과 같게: 버튼이 있는 안내는 사용자가 닫을 때까지 ([✕]). 나중에는 MKV 목록의 그 동영상 세부 정보에서도 persist: true, showCloseIcon: true, 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: 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); /// 138: "AI 자막 (동영상 n개 · 약 m분) 을 시작합니다" (길이를 모르면 시간 없이) String aiStartNotice(List videos, AiOptions o, {required bool thenBuild}) { final known = videos.map((v) => v.info?.duration).whereType().toList(); final est = known.length == videos.length ? aiEstimateMinutes(known.fold(Duration.zero, (a, b) => a + b), aiTranslateCount(o)) : null; final what = [trf('동영상 {0}개', [videos.length]), if (est != null) aiEstimateText(est)].join(' · '); return thenBuild ? trf('AI 자막 → MKV ({0}) 을 시작합니다', [what]) : trf('AI 자막 ({0}) 을 시작합니다', [what]); } /// 138: AI 작업 (자막 · 자막 & MKV) 의 색 - [MKV 만들기] (청록) 와 헷갈리지 않게 Color get aiColor => JjColors.isLight ? const Color(0xFF6A3FC8) : const Color(0xFFB388FF); // ⑦ 밝은 화면은 진한 보라 (대비 4.5:1) /// 138: AI 작업 시간 어림 (분): 영상 1시간 ≈ 음성인식 15~30분 + 번역할 언어마다 10~20분. 길이를 모르면 null (int, int)? aiEstimateMinutes(Duration total, int translateLanguages) { if (total <= Duration.zero) return null; final h = total.inSeconds / 3600; return ((h * (15 + 10 * translateLanguages)).ceil(), (h * (30 + 20 * translateLanguages)).ceil()); } /// 197: 예상 시간 글 - 짧은 동영상은 "약 1~1분" 처럼 같은 수가 두 번 보였다 String aiEstimateText((int, int) est) { final (lo, hi) = est; if (hi <= 1) return tr('1분 안'); if (lo == hi) return trf('약 {0}분', [lo]); return trf('약 {0}~{1}분', [lo, hi]); } /// 번역할 언어 수 (원어와 같은 언어는 번역하지 않음, 원어 자동 감지면 하나는 원어로 본다) int aiTranslateCount(AiOptions o) => o.source == undetermined ? (o.targets.length - 1).clamp(0, 99) : o.targets.where((t) => t.code != o.source.code).length; /// 138: 시작 전에 보여 줄 계획: 할 일 · 대상 수 · 자막 언어 · 예상 시간 class AiPlanSummary extends StatelessWidget { final List videos; final AiOptions o; final bool thenBuild; const AiPlanSummary({super.key, required this.videos, required this.o, required this.thenBuild}); @override Widget build(BuildContext context) { final known = videos.map((v) => v.info?.duration).whereType().toList(); final total = known.fold(Duration.zero, (a, b) => a + b); final est = known.length == videos.length ? aiEstimateMinutes(total, aiTranslateCount(o)) : null; String hm(Duration d) => d.inHours > 0 ? trf('{0}시간 {1}분', [d.inHours, d.inMinutes % 60]) : trf('{0}분', [d.inMinutes.clamp(1, 59)]); final rows = [ (tr('할 일'), thenBuild ? tr('AI 자막 만들기 → 이어서 MKV 만들기') : tr('AI 자막 만들기 (MKV 는 만들지 않음)')), (tr('대상'), trf('동영상 {0}개', [videos.length])), (tr('자막 언어'), [for (final l in o.targets) l.name].join(' · ')), ( tr('예상 시간'), est == null ? tr('동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)') : trf('{0} (동영상 {1})', [aiEstimateText(est), hm(total)]), ), ]; return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: aiColor.withValues(alpha: 0.08), border: Border.all(color: aiColor.withValues(alpha: 0.5)), borderRadius: BorderRadius.circular(8)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (final (k, v) in rows) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 84, child: Text(k, style: TextStyle(fontSize: 12, color: JjColors.textDim))), Expanded(child: Text(v, style: const TextStyle(fontSize: 13))), ]), ), ]), ); } } class _AiDialog extends StatefulWidget { final AppController c; final int videoCount; /// 이번에 자막을 만들 동영상 · 이어서 MKV 도 만드는지 (제목 · 계획에 표시) final List videos; final bool thenBuild; const _AiDialog({required this.c, required this.videos, required this.videoCount, this.thenBuild = false}); int get targetCount => videos.length; @override State<_AiDialog> createState() => _AiDialogState(); } class _AiDialogState extends State<_AiDialog> { late AiOptions _o = widget.c.aiOptions; bool _all = false; final Map _installed = {}; @override void initState() { super.initState(); for (final m in [...whisperModels, nllbModel]) { widget.c.services.models.isInstalled(m).then((ok) { if (mounted) setState(() => _installed[m.id] = ok); }); } } Widget _status(ModelSpec m) { final ok = _installed[m.id]; if (ok == null) return const SizedBox(); return Text(ok ? tr('설치됨') : trf('처음 사용 시 내려받기 {0}', [m.sizeLabel]), style: TextStyle(fontSize: 11, color: ok ? JjColors.success : JjColors.textDim)); } /// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다 Widget _nllbStatus() { if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel); final style = TextStyle(fontSize: 11, color: JjColors.textDim); if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style); return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style); } @override Widget build(BuildContext context) { final extra = languages.where((l) => !_o.targets.contains(l)).toList(); return AlertDialog( title: Text(trf('AI 자막 만들기{0}' '{1}', [widget.thenBuild ? tr(' → MKV 만들기') : '', widget.targetCount > 1 ? trf(' (동영상 {0}개)', [widget.targetCount]) : ''])), content: SizedBox( width: 520, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(trf('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.', [Platform.isAndroid ? tr('기기') : 'PC']), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 10), // 138: 무엇을 할지 먼저 (바꾸면 따라 바뀐다) AiPlanSummary(videos: _all ? widget.c.videos : widget.videos, o: _o, thenBuild: widget.thenBuild), const SizedBox(height: 16), _label(tr('원어 (영상 속 언어)')), DropdownButton( value: _o.source, isExpanded: true, items: [ DropdownMenuItem(value: undetermined, child: Text(tr('자동 감지'))), for (final l in languages) DropdownMenuItem(value: l, child: Text('${l.name} (${l.code})')), ], onChanged: (l) => setState(() => _o = _o.copyWith(source: l)), ), const SizedBox(height: 16), _label(tr('만들 자막 언어 (파일명_언어코드.srt)')), Wrap( spacing: 6, runSpacing: 6, children: [ for (final l in _o.targets) InputChip( label: Text('${l.name} (${l.code})'), onDeleted: _o.targets.length > 1 ? () => setState(() => _o = _o.copyWith(targets: {..._o.targets}..remove(l))) : null, ), PopupMenuButton( tooltip: tr('언어 추가'), itemBuilder: (_) => [ for (final l in extra) PopupMenuItem(value: l, child: Text('${l.name} (${l.code})')), ], onSelected: (l) => setState(() => _o = _o.copyWith(targets: {..._o.targets, l})), child: Chip(avatar: Icon(Icons.add, size: 16), label: Text(tr('언어 추가'))), ), ], ), const SizedBox(height: 16), _label(tr('음성인식 모델')), RadioGroup( groupValue: _o.whisper.id, onChanged: (id) => setState(() => _o = _o.copyWith(whisper: whisperModels.firstWhere((m) => m.id == id))), child: Column(children: [ for (final m in whisperModels) RadioListTile( dense: true, contentPadding: EdgeInsets.zero, value: m.id, title: Text(m.label), subtitle: _status(m), ), ]), ), Row(children: [ Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)), Flexible(child: _nllbStatus()), ]), const NllbLicenseNote(), if (widget.videoCount > 1) ...[ const SizedBox(height: 8), CheckboxListTile( dense: true, contentPadding: EdgeInsets.zero, value: _all, onChanged: (v) => setState(() => _all = v ?? false), title: Text(trf('목록의 동영상 {0}개 모두', [widget.videoCount])), ), ], const SizedBox(height: 8), Text(tr('※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)'), style: TextStyle(fontSize: 11, color: JjColors.textDim)), ], ), ), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton.icon( style: FilledButton.styleFrom(backgroundColor: aiColor), onPressed: () => Navigator.pop(context, (_o, _all)), icon: const Icon(Icons.auto_awesome, size: 18), label: Text(tr('시작')), ), ], ); } Widget _label(String t) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(t, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), ); }