From 16892210191538be60e1ed2b5d1b29a4181fadf2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:26:10 +0900 Subject: [PATCH] =?UTF-8?q?home=5Fpage=20=EB=82=98=EB=88=84=EA=B8=B0=20(15?= =?UTF-8?q?66=20=E2=86=92=20190):=20=EC=9C=84=EC=AA=BD=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20MKV=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EC=84=B8=ED=9E=88=20=C2=B7=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=9E=91=EC=97=85=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=8F=99=EC=9D=84=20lib/ui/home/=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 조각은 공개 위젯 (key 받음), 일은 AppController 가 · 화면은 부르기만 - 약속 시험: LogPanel · LogStrip · JobStatusIcon Co-Authored-By: Claude Opus 5.5 --- lib/ui/home/encode_bar.dart | 198 ++++ lib/ui/home/home_top_bar.dart | 399 ++++++++ lib/ui/home/log_panel.dart | 86 ++ lib/ui/home/move_to_target.dart | 48 + lib/ui/home/subtitle_row.dart | 186 ++++ lib/ui/home/video_detail.dart | 230 +++++ lib/ui/home/video_list_view.dart | 319 ++++++ lib/ui/home_page.dart | 1406 +------------------------- test/home_modules_contract_test.dart | 35 + 9 files changed, 1516 insertions(+), 1391 deletions(-) create mode 100644 lib/ui/home/encode_bar.dart create mode 100644 lib/ui/home/home_top_bar.dart create mode 100644 lib/ui/home/log_panel.dart create mode 100644 lib/ui/home/move_to_target.dart create mode 100644 lib/ui/home/subtitle_row.dart create mode 100644 lib/ui/home/video_detail.dart create mode 100644 lib/ui/home/video_list_view.dart create mode 100644 test/home_modules_contract_test.dart diff --git a/lib/ui/home/encode_bar.dart b/lib/ui/home/encode_bar.dart new file mode 100644 index 0000000..6a9fadd --- /dev/null +++ b/lib/ui/home/encode_bar.dart @@ -0,0 +1,198 @@ +/// 약속: 다운로드 상자와 인코딩 막대 (코덱 · 크기 · 화질 · 보정) - 바꾸기는 AppController 가. +library; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/download_manager.dart'; +import '../../core/encode_options.dart'; +import '../app_actions.dart'; +import '../downloads_page.dart'; +import '../encode_notice.dart'; +import '../theme.dart'; +import '../video_adjust_dialog.dart'; +import '../../l10n/tr.dart'; + +/// 상단 "다운로딩" 상자: 누르면 다운로드 목록 +class HomeDownloadBox extends StatelessWidget { + final DownloadManager d; + const HomeDownloadBox({super.key, required this.d}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: d, + builder: (context, _) { + final n = d.downloadingCount; + final p = d.overallProgress; + return InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => DownloadsPage.open(Navigator.of(context), d), + child: Container( + width: 150, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: n > 0 ? JjColors.accent : JjColors.border), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.downloading, size: 16, + color: n > 0 ? JjColors.accent : JjColors.textDim), + const SizedBox(width: 6), + Text( + n > 0 + ? trf('다운로딩 {0}{1}', [n, p == null ? '' : ' · ${(p * 100).toStringAsFixed(0)}%']) + : trf('다운로드 {0}', [d.tasks.length]), + style: const TextStyle(fontSize: 12)), + ]), + if (n > 0) ...[ + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: p, minHeight: 4, backgroundColor: JjColors.panelHigh), + ), + ], + ], + ), + ), + ); + }, + ); +} + +// ───────── 화면 크기 · 코덱 · 화질 ───────── + +class EncodeBar extends StatelessWidget { + final AppController c; + const EncodeBar({super.key, required this.c}); + + @override + Widget build(BuildContext context) { + final s = c.encode; + // 32: MKV 를 만드는 동안만 (AI 자막 · 번역 중에는 다음 MKV 설정을 바꿀 수 있다) + final locked = c.buildingMkv; + final up = c.upscaleCount; + + Widget label(String t) => Padding( + padding: const EdgeInsets.only(right: 6), + child: Text(t, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ); + // 253: 글자가 크면 고르기 상자가 화면보다 넓어져 넘쳤다 - 제 폭 (가장 긴 항목) 을 쓰되 화면 폭을 넘지 않게 (글은 …) + final maxDrop = (MediaQuery.sizeOf(context).width - 40).clamp(120.0, double.infinity); + Widget drop(T value, List items, String Function(T) text, + ValueChanged onChanged, {bool Function(T)? enabled}) => + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxDrop), + child: IntrinsicWidth( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + underline: const SizedBox(), + style: TextStyle(fontSize: 13, color: JjColors.text), + items: [ + for (final i in items) + DropdownMenuItem( + value: i, + enabled: enabled?.call(i) ?? true, + child: Text( + text(i) + ((enabled?.call(i) ?? true) ? '' : tr(' (사용 불가)')), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: (enabled?.call(i) ?? true) ? null : JjColors.textDim), + ), + ), + ], + onChanged: locked ? null : (v) => onChanged(v as T), + ), + ), + ); + + final compact = isCompact(context); + final row = Row(children: [ + label(tr('화면 크기')), + drop(s.resolution, ResolutionChoice.values, (r) => r.label, (r) { + c.setResolution(r); + showEncodeNotice(context, c); + }), + const SizedBox(width: 20), + label(tr('코덱')), + drop(s.codec, VideoCodecChoice.values, (v) => v.label, c.setCodec, + enabled: c.isCodecAvailable), + const SizedBox(width: 20), + if (s.reencode) ...[ + label(tr('화질')), + drop(s.quality, QualityChoice.values, (q) => q.label, c.setQuality), + const SizedBox(width: 20), + ], + // 화면 비율 (가로 · 세로) · 회전 · 색 보정: 미리보기를 보며 고르는 창 + Tooltip( + message: s.adjusts ? '${tr('화면 · 색 보정')}: ${s.adjustSummary}' : tr('화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도'), + child: (s.adjusts ? FilledButton.tonalIcon : OutlinedButton.icon)( + onPressed: locked ? null : () => showVideoAdjust(context, c), + icon: const Icon(Icons.tune, size: 18), + label: Text(tr('화면 · 색 보정')), + ), + ), + const SizedBox(width: 12), + Expanded( + // 31: "⚠ 원본보다 커서" 경고는 화면 너비와 상관없이 늘 보이게 맨 앞에 (넓은 화면에서 한 줄이 넘치면 설명 · 보정 요약 쪽이 잘린다) + child: Text( + '${s.reencode && up > 0 ? '${trf('⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다', [up])} · ' : ''}' + '${s.adjusts ? s.adjustSummary : !s.reencode ? tr('영상·음성을 그대로 복사합니다 (빠름, 화질 손실 없음)') : tr('영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로)')}', + overflow: compact ? null : TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim), + ), + ), + ]); + // 241 시험에서 찾음: 폭 720 ~ 840 (노트 10+ 가로 등) 에서 한 줄이 넘쳤다 - 960 보다 좁으면 폰처럼 줄을 바꿔 보인다 (내 판단) + // 256: 낮은 화면 (폰 가로) 은 여러 줄로 감기면 목록이 한 줄만 보였다 - 한 줄로 옆으로 밀게. + // 31: 설명 · "⚠ 원본보다 커서" 경고는 맨 앞에 (밀지 않아도 보이게) + if (!compact && MediaQuery.sizeOf(context).height < 500) { + final text = [for (final w in row.children) if (w is Expanded) w.child]; + return Container( + width: double.infinity, + height: 44, + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + for (final t in text) ...[ConstrainedBox(constraints: const BoxConstraints(maxWidth: 360), child: t), const SizedBox(width: 16)], + for (final w in row.children) if (w is! Expanded) w, + ]), + ), + ); + } + if (compact || MediaQuery.sizeOf(context).width < 960) { + return Container( + width: double.infinity, + color: JjColors.panel, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 6, + // 31: 설명 · "⚠ 원본보다 커서 용량만 늘어납니다" 경고는 빼지 않고 한 줄을 다 써서 보여 준다 + children: [ + for (final w in row.children) + w is Expanded ? SizedBox(width: double.infinity, child: w.child) : w, + ], + ), + ); + } + return Container( + height: 44, + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: row, + ); + } +} + +// ───────── 왼쪽: 동영상 목록 ───────── diff --git a/lib/ui/home/home_top_bar.dart b/lib/ui/home/home_top_bar.dart new file mode 100644 index 0000000..6f8802a --- /dev/null +++ b/lib/ui/home/home_top_bar.dart @@ -0,0 +1,399 @@ +/// 약속: MKV 화면 위쪽 막대 (넣기 · 만들기 · 취소 · 결과 폴더 · 작업 현황) - 일은 AppController 가. +library; + +import 'dart:math' as math; +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/bookmarks_controller.dart'; +import '../../app/download_manager.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../ai_dialog.dart'; +import '../app_actions.dart'; +import '../scroll_more.dart'; +import '../existing_output_dialog.dart'; +import '../player_page.dart'; +import '../theme.dart'; +import '../work_panel.dart'; +import '../../l10n/tr.dart'; +import 'encode_bar.dart'; + +class HomeTopBar extends StatelessWidget { + final AppController c; + final DownloadManager? downloads; + final VoidCallback? onExit; + final BookmarksController? bookmarks; + const HomeTopBar({super.key, required this.c, this.downloads, this.onExit, this.bookmarks}); + + /// 고른 동영상이 없을 때: 목록 전체를 만들지 묻는다 + /// 보고 있는 동영상의 결과 폴더 (jj_mkv) 를 탐색기 · 파일 앱으로. 아직 없으면 알린다. + /// 45: 대기 중인 작업이 있으면 지금 작업만 / 모두 고르게 한다 (없으면 지금 작업을 바로 멈춤 - 예전처럼) + /// 186: 한 묶음 안의 차례를 기다리는 동영상 (MKV 만들기 3개 중 v2 · v3) 도 대기로 센다 - 남은 것이 있으면 + /// "지금 것만 취소 · 남은 것 모두 취소 · 그대로 두기" 를 묻는다 (예전에는 묻지 않고 모두 멈췄다) + Future _cancelJobs(BuildContext context) async { + final inBatch = c.batchWaiting; + final waiting = c.pendingJobs.length + inBatch; + // 239: 창을 연 때 돌고 있던 것 - [지금 것만 취소] 는 이것만 (그사이 끝나고 다음 것이 시작해도 다음 것은 취소하지 않는다) + final targets = c.buildingMkv ? c.runningVideos : const []; + if (waiting == 0) { + // 240: 여럿이 동시에 돌고 있으면 묻는다 (하나씩은 목록 줄의 ■) + if (targets.length > 1) { + final all = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, + title: Text(tr('작업 취소')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])), + const SizedBox(height: 4), + Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(trf('모두 취소 ({0}개)', [targets.length])), + ), + ], + ), + ); + if (all == true) c.cancel(); + return; + } + c.cancel(); + return; + } + if (inBatch > 0) { + final now = targets.map((v) => v.fileName).join(', '); + final pick = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, // 110: [그대로 두기] 가 먼저 + title: Text(tr('작업 취소')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('지금: {0}', [now.isEmpty ? (c.currentJob ?? '') : now])), + const SizedBox(height: 4), + Text(trf('남은 것: MKV 만들기 {0}개{1}', [inBatch, c.pendingJobs.isEmpty ? '' : ' · ${c.pendingJobs.join(' · ')}']), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'current'), child: Text(tr('지금 것만 취소 (다음 것부터 이어서)'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, 'all'), + child: Text(trf('남은 것 모두 취소 ({0}개)', [waiting])), + ), + ], + ), + ); + if (pick == 'current') { + final gone = c.cancelVideos(targets); + // 239: 창을 보는 사이 이미 끝났으면 알리고 지금 상태로 다시 묻는다 + if (gone.isNotEmpty && context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(trf('{0} 는 이미 끝났습니다', [gone.map((v) => v.fileName).join(', ')])))); + if (c.busy) await _cancelJobs(context); + } + } + if (pick == 'all') c.cancel(); + return; + } + final pick = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('작업 취소')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('지금: {0}', [c.currentJob ?? ''])), + const SizedBox(height: 4), + Text(trf('대기 중: {0}', [c.pendingJobs.join(' · ')]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'current'), child: Text(trf('지금 작업만 (대기 {0}개는 계속)', [waiting]))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, 'all'), + child: Text(trf('모두 취소 (대기 {0}개 포함)', [waiting])), + ), + ], + ), + ); + if (pick == 'current') c.cancelCurrent(); + if (pick == 'all') c.cancel(); + } + + Future _openOutput(BuildContext context) async { + final v = c.selected; + if (v == null) return; + final dir = outputDirFor(v.path); + if (!await Directory(dir).exists()) { + if (context.mounted) { + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(trf('아직 만든 MKV · 자막이 없습니다: {0}', [dir])))); + } + return; + } + await c.services.shell.revealFile(dir); + } + + Future _confirmBuildAll(BuildContext context) async { + final todo = c.videos.where((v) => v.status != JobStatus.done).length; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('MKV 만들기')), + content: Text(trf('선택한 동영상이 없습니다.\n목록 전체 {0}개 ' '(이미 만든 것을 빼면 {1}개) 를 MKV 로 만들까요?\n\n' '일부만 만들려면 취소하고 동영상 목록에서 체크하세요.', [c.videos.length, todo])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('전체 만들기'))), + ], + ), + ); + // P0: 결과 MKV 가 이미 있으면 먼저 묻는다 + if (ok == true && context.mounted) await buildAllAsking(context, c); + } + + static String _batchHint(AppController c, String what) => c.checked.isEmpty + ? trf('지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)', [what]) + : trf('체크한 동영상 {0}개의 {1}', [c.checked.length, what]); + + @override + Widget build(BuildContext context) { + final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null; + // 일괄 작업 대상: 체크한 동영상, 없으면 지금 보고 있는 한 개 + final batch = c.batchTargets; + final canBatch = batch.isNotEmpty && c.ffmpegVersion != null; + final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; + // 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김. + // (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게) + final compact = isCompact(context); + return LayoutBuilder(builder: (context, box) { + final hasAi = c.aiAvailable; + final hasPlayer = c.services.createMediaPlayer != null; + var title = true, leftText = true, rightText = true, usage = c.usage != null, jobText = true; + // 아주 좁을 때: 다운로드 상자 숨김 (왼쪽 공통 버튼으로 열 수 있음) → MKV 만들기도 아이콘만 + var dlBox = downloads != null, mkvText = true; + // 각 부분의 너비 (실제 화면에서 잰 값 - 한국어 글 기준) + // 61: 다른 언어는 버튼 글이 더 길 수 있어 (일본어 "字幕作成 & MKV 作成") 그 차이를 재어 더한다 - 안 하면 줄이기 전에 잘렸다 + final style = Theme.of(context).textTheme.labelLarge ?? const TextStyle(fontSize: 14); + double textW(String s) => (TextPainter(text: TextSpan(text: s, style: style), textDirection: TextDirection.ltr, maxLines: 1) + ..layout()) + .width; + double more(String ko) => uiLanguage == 'ko' ? 0 : math.max(0, textW(tr(ko).replaceAll('{0}', '').replaceAll('{1}', '')) - textW(ko.replaceAll('{0}', '').replaceAll('{1}', ''))); + final leftMore = more('동영상 추가') + (hasAi ? more('자막 만들기{0}') + more('자막 만들기 & MKV 만들기{0}') : 0); + final playMore = more('선택한 파일 재생{0}'), mkvMore = more('MKV 만들기{0}{1}'), outMore = more('결과 폴더'); + // 좁은 화면: 이동 버튼 · 설정 · 종료는 첫 줄에 있으므로 둘째 줄 (가운데 도구) 만 잰다 + double need() => + (compact ? 0 : 8 + appBarRightPadding + AppNavButtons.width + 8) + (title ? 126 : 0) + 24 + + (leftText ? 153 + (hasAi ? 161 + 298 : 0) + leftMore : 40 + (hasAi ? 96 : 0)) + 8 + + (c.busy ? (jobText ? 350 : 60) : 0) + + (hasPlayer ? (rightText ? 201 + playMore : 48) : 0) + + (mkvText ? 166 + mkvMore + (c.busy && jobText ? 60 : 0) : 48) + + (rightText ? 166 + outMore : 48) + // 결과 폴더 + (dlBox ? 162 : 0) + + (compact ? 0 : (usage ? 220 : 0) + 96 + 92) + + (c.checked.isEmpty ? 0 : 3 * 26) + + 8; // 여유 + final w = box.maxWidth; + if (compact) title = false; + if (need() > w) title = false; + if (need() > w) jobText = false; + if (need() > w) leftText = false; + if (need() > w) rightText = false; + if (need() > w) usage = false; + if (need() > w) dlBox = false; + if (need() > w) mkvText = false; + // 242: 다 줄여도 모자라는지는 어림 (need) 이 아니라 위 막대가 실제로 잰다 (오른쪽 묶음 [trailing] · minMiddleWidth) + + // 7: 폰에는 "탐색기에서 끌어다 놓기" 가 없다 + final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); + + // 글이 있는 버튼, 또는 (좁을 때) 아이콘만 있는 버튼 + // 138: [ai] 는 AI 가 들어가는 버튼 (보라 테두리 · 글자) - 청록 [MKV 만들기] 와 한눈에 구별되게 + Widget action(IconData icon, String label, VoidCallback? onPressed, + {required bool text, required String tip, Color? color, bool ai = false}) { + final fg = ai ? aiColor : null; + final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border); + // 241: 아이콘만 있는 버튼은 이름을 버튼 자체에 (바깥 Tooltip 은 TalkBack 이 읽는 이름이 되지 않았다) + if (!text) { + return IconButton.outlined( + tooltip: tip, + onPressed: onPressed, + icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg), + style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)); + } + return Tooltip( + message: tip, + child: OutlinedButton.icon( + style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, + onPressed: onPressed, + icon: Icon(icon, size: 18, color: color ?? fg), + label: Text(label)), + ); + } + + // AI 버튼 둘 (자막 만들기 · 자막 만들기 & MKV 만들기) + List aiButtons(bool text) => [ + action(Icons.auto_awesome, trf('자막 만들기{0}', [count]), + canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, + text: text, ai: true, tip: trf('자막 만들기: {0}', [_batchHint(c, tr('AI 자막을 만듭니다'))])), + const SizedBox(width: 8), + action(Icons.auto_awesome_motion, trf('자막 만들기 & MKV 만들기{0}', [count]), + canBatch ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) : null, + text: text, + ai: true, + tip: trf('자막 만들기 & MKV 만들기: {0}', [_batchHint(c, tr('AI 자막을 만들고 이어서 MKV 로 만듭니다'))])), + ]; + + // 2: 좁은 화면 (폰 세로) 에서는 버튼 글자를 숨기지 않고 옆으로 밀어 본다. 가장 많이 쓰는 [MKV 만들기] 를 맨 앞에. + if (compact) { + VoidCallback? build0() => + !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)); + return AppTopBar( + nav: const AppNavButtons(onMkvPage: true), + actions: AppActions(c: c, onExit: onExit, showUsage: false), + // 15 지적: 폭 412 에서 세 번째 버튼이 잘렸는데 더 있다는 표시가 없었다 - 끝에 흐림 · [›] + middle: ScrollMore( + background: JjColors.panel, + child: Row(children: [ + Tooltip( + message: trf('MKV 만들기{0}', [count]), + child: FilledButton.icon( + onPressed: build0(), + icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), + label: Text(trf('MKV 만들기{0}{1}', [count, c.busy ? tr(' (대기열)') : ''])), + ), + ), + const SizedBox(width: 8), + action(Icons.add, tr('동영상 추가'), c.pickVideos, text: true, tip: addTip), + if (hasPlayer) ...[ + const SizedBox(width: 8), + action( + Icons.playlist_play, + trf('선택한 파일 재생{0}', [count]), + batch.isEmpty ? null : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), + text: true, + tip: tr('선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)')), + ], + const SizedBox(width: 8), + action(Icons.folder_special_outlined, tr('결과 폴더'), c.selected == null ? null : () => _openOutput(context), + text: true, tip: tr('결과 폴더 열기')), + // 138: AI 버튼은 [MKV 만들기] 와 떨어뜨려 맨 뒤에, 구분선 너머로 + if (hasAi) ...[ + const SizedBox(width: 8), + SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)), + ...aiButtons(true), + ], + ]), + ), + // P0-4: 작업 중에는 진행 · [취소] 를 줄 끝 (늘 보이는 곳) 에 - 예전에는 옆으로 미는 줄의 맨 끝이라 두 번 밀어야 보였다 + trailing: c.busy + ? Row(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(width: 6), + JobIndicator(c: c, compact: true, iconOnly: true), + const SizedBox(width: 4), + action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), + ]) + : null, + ); + } + + return AppTopBar( + // 242: 작업 중 (진행 표시 · 취소) 이 생기거나 없어지면 다시 잰다 + // (여러 개를 만드는지에 따라 진행 표시 폭이 달라 그때도 다시 잰다) + key: ValueKey(('mkvTopBar', c.busy, c.batchTotal > 1)), + // 242: 오른쪽 묶음 때문에 왼쪽 (동영상 추가 · AI) 이 아이콘 하나보다 좁아지면 두 줄 + minMiddleWidth: 56, + // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] + nav: const AppNavButtons(onMkvPage: true), + // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 + actions: AppActions(c: c, onExit: onExit, showUsage: usage), + middle: Row( + children: [ + if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + SizedBox(width: compact ? 0 : 24), + // 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게 + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + action(Icons.add, tr('동영상 추가'), c.pickVideos, + text: leftText, tip: addTip), + // 138: AI 버튼 (보라) 은 왼쪽, [MKV 만들기] (청록) 는 오른쪽 - 떨어져 있다 + if (hasAi) ...[ + const SizedBox(width: 8), + ...aiButtons(leftText), + ], + ]), + ), + ), + const SizedBox(width: 8), + ], + ), + // 오른쪽 묶음 (줄지 않음): 작업 · 재생 · MKV 만들기 · 결과 폴더 · 다운로드 + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (c.busy) ...[ + JobIndicator(c: c, compact: !jobText, iconOnly: !jobText), + const SizedBox(width: 8), + action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: jobText, tip: tr('작업 취소'), color: JjColors.danger), + const SizedBox(width: 8), + ], + // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) + if (hasPlayer) ...[ + action( + Icons.playlist_play, + trf('선택한 파일 재생{0}', [count]), + batch.isEmpty + ? null + : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), + text: rightText, + tip: c.checked.isEmpty + ? tr('선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)') + : trf('선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서', [c.checked.length])), + const SizedBox(width: 8), + ], + // 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다 + Tooltip( + // 241: 아이콘만일 때는 버튼 자체의 tooltip (이름) 을 쓰고 바깥은 비운다 (두 번 뜨지 않게) + message: mkvText ? trf('MKV 만들기{0}', [count]) : '', + child: mkvText + ? FilledButton.icon( + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), + icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), + label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), + ) + : IconButton.filled( + tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름 + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), + icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), + ), + ), + // 만든 MKV 가 있는 폴더 (jj_mkv). 웹 브라우저 버튼은 왼쪽 공통 버튼으로 옮김 + const SizedBox(width: 8), + action(Icons.folder_special_outlined, tr('결과 폴더'), c.selected == null ? null : () => _openOutput(context), + text: rightText, + tip: c.selected == null + ? tr('결과 폴더 열기 (동영상을 고르세요)') + : trf('결과 폴더 열기: {0}', [outputDirFor(c.selected!.path)])), + if (dlBox) ...[ + const SizedBox(width: 12), + HomeDownloadBox(d: downloads!), + ], + ], + ), + ); + }); + } +} diff --git a/lib/ui/home/log_panel.dart b/lib/ui/home/log_panel.dart new file mode 100644 index 0000000..da67eeb --- /dev/null +++ b/lib/ui/home/log_panel.dart @@ -0,0 +1,86 @@ +/// 약속: 작업 기록을 보여 준다 (넓은 화면은 아래 칸, 좁은 화면은 한 줄 - 누르면 전체). +library; + +import 'package:flutter/material.dart'; + +import '../theme.dart'; +import '../../l10n/tr.dart'; + +class LogPanel extends StatelessWidget { + final List logs; + final VoidCallback onClose; + const LogPanel({super.key, required this.logs, required this.onClose}); + + /// 폰 세로에서 기록이 비었을 때의 높이 (안내 한 줄) + static const emptyHeight = 40.0; + + @override + Widget build(BuildContext context) => Container( + color: JjColors.bg, + child: Stack(children: [ + // 15 지적: 기록이 없으면 ✕ 하나만 있는 빈 칸으로 보였다 - 무엇이 들어오는 칸인지 적는다 + if (logs.isEmpty) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 44, 0), + child: Text(tr('작업 기록이 아직 없습니다. MKV 만들기 · 자막 · 다운로드를 하면 여기에 차례로 남습니다.'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ), + Positioned.fill( + child: ListView.builder( + reverse: true, + padding: const EdgeInsets.fromLTRB(16, 8, 44, 8), + itemCount: logs.length, + itemBuilder: (_, i) => SelectableText( + logs[logs.length - 1 - i], + style: TextStyle( + fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), + ), + ), + ), + // 오른쪽 위 ✕: 작업 기록 숨기기 + Positioned( + top: 2, + right: 4, + child: IconButton( + tooltip: tr('작업 기록 숨기기'), + iconSize: 16, + visualDensity: VisualDensity.compact, + icon: Icon(Icons.close, color: JjColors.textDim), + onPressed: onClose, + ), + ), + ]), + ); +} + +/// 작업 기록을 숨겼을 때 맨 아래 얇은 줄: 마지막 기록 + 다시 보기 +class LogStrip extends StatelessWidget { + final String last; + final VoidCallback onOpen; + const LogStrip({super.key, required this.last, required this.onOpen}); + + @override + Widget build(BuildContext context) => Material( + color: JjColors.bg, + child: InkWell( + onTap: onOpen, + child: SizedBox( + height: 26, + child: Row(children: [ + const SizedBox(width: 16), + Expanded( + child: Text(last, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)), + ), + Text(tr('작업 기록 보기'), style: TextStyle(fontSize: 11, color: JjColors.accent)), + Icon(Icons.expand_less, size: 16, color: JjColors.accent), + const SizedBox(width: 12), + ]), + ), + ), + ); +} diff --git a/lib/ui/home/move_to_target.dart b/lib/ui/home/move_to_target.dart new file mode 100644 index 0000000..a13fffe --- /dev/null +++ b/lib/ui/home/move_to_target.dart @@ -0,0 +1,48 @@ +/// 약속: MKV 목록의 동영상을 이동 버튼 폴더로 옮긴다 (확인 · 결과 알림). +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/app_controller.dart'; +import '../../core/models.dart'; +import '../../app/settings.dart' show MoveTarget; +import '../folder_picker.dart'; +import '../confirm.dart'; +import '../../l10n/tr.dart'; + +/// 동영상을 이동 버튼의 폴더로 옮긴다 (세부 정보의 이동 버튼 · 세 번 누르기). [target] 이 없으면 첫 번째 버튼, +/// 이동 버튼이 하나도 없으면 폴더를 골라 하나 만든다 (표시 이름은 폴더 이름). +/// [confirm]: 옮기기 전에 묻는다 (세 번 누르기 - 47) +Future moveToTarget(BuildContext context, AppController c, List videos, + {MoveTarget? target, bool confirm = false}) async { + if (videos.isEmpty) return; + final messenger = ScaffoldMessenger.maybeOf(context); + var t = target ?? c.settings.mkv.moveTargets.firstOrNull; + if (t == null) { + final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)')); + if (d == null) return; + t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d); + final add = t; + await c.updateSettings((x) => x.mkv.moveTargets = [...x.mkv.moveTargets, add]); + } else if (confirm && context.mounted) { + final ok = await confirmAction( + context, + title: tr('파일 옮기기'), + body: '${trf('"{0}" 을(를) "{1}" 폴더로 옮길까요?', [videos.length == 1 ? videos.single.fileName : trf('{0}개', [videos.length]), t.name])}' + '\n${t.dir}\n\n${tr('(동영상을 세 번 누르면 옮깁니다)')}', + ok: tr('옮기기'), + danger: false, + ); + if (!ok) return; + } + final (moved, errors) = await c.moveVideos(videos, t.dir); + messenger?.showSnackBar(SnackBar( + content: Text([ + if (moved > 0) trf('{0}개를 옮겼습니다 → {1} ({2})', [moved, t.name, t.dir]), + ...errors, + ].join('\n')), + )); +} diff --git a/lib/ui/home/subtitle_row.dart b/lib/ui/home/subtitle_row.dart new file mode 100644 index 0000000..0184ab0 --- /dev/null +++ b/lib/ui/home/subtitle_row.dart @@ -0,0 +1,186 @@ +/// 약속: 자막 한 줄 (언어 · 문자셋 고르기 · 편집 · 번역 · 빼기) - 바꾸기는 AppController 가. +library; + +import 'dart:math' as math; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../core/charset_detector.dart'; +import '../../core/languages.dart'; +import '../../core/models.dart'; +import '../subtitle_editor_page.dart'; +import '../theme.dart'; +import '../translate_dialog.dart'; +import '../../l10n/tr.dart'; + +class SubtitleRow extends StatelessWidget { + final AppController c; + final VideoItem v; + final SubtitleEntry s; + final bool locked; + const SubtitleRow( + {super.key, required this.c, required this.v, required this.s, required this.locked}); + + /// 관리자 지시 (10/21, 15 찾음 F6): 한 줄에 들어가는 폭 - 내장 표 · 이름 (최소) · 언어 고르기 · 문자셋 · 단추. + /// 고르기는 가장 긴 항목 글의 폭을 쓰므로 (DropdownButton) 그 글을 지금 글꼴 · 글자 배율로 잰다 + static double _oneLineWidth(BuildContext context, {required bool embedded, required int buttons}) { + final scaler = MediaQuery.textScalerOf(context); + final base = DefaultTextStyle.of(context).style; + double textW(String t, double size) => + (TextPainter(text: TextSpan(text: t, style: base.copyWith(fontSize: size)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1) + ..layout()) + .width; + double widest(Iterable xs, double size) => xs.fold(0.0, (m, t) => math.max(m, textW(t, size))); + final lang = widest([tr('언어: 미지정'), for (final l in languages) '${l.name} (${l.code})'], 12) + 24; + final charset = embedded ? 0.0 : widest(supportedCharsets.values, 12) + 24 + 8; + final badge = textW(embedded ? tr('내장') : tr('외부'), 11) + 12; + const nameMin = 60.0; + return badge + 10 + nameMin + 8 + lang + charset + 4 + buttons * 48; + } + + @override + Widget build(BuildContext context) { + final embedded = s.kind == SubtitleKind.embedded; + final off = !s.enabled; + final badge = Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (embedded ? JjColors.textDim : JjColors.accent) + .withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(4), + ), + child: Text(embedded ? tr('내장') : tr('외부'), + style: TextStyle( + fontSize: 11, + color: embedded ? JjColors.textDim : JjColors.accent)), + ); + final name = Text( + s.displayName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: off ? JjColors.textDim : JjColors.text, + decoration: off ? TextDecoration.lineThrough : null, + ), + ); + final pickers = [ + LanguageDropdown( + value: s.language, + onChanged: locked || off ? null : (l) => c.setLanguage(s, l), + ), + if (!embedded) + CharsetDropdown( + value: s.charset ?? 'UTF-8', + onChanged: locked ? null : (cs) => c.setCharset(s, cs), + ), + ]; + final buttons = [ + IconButton( + tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'), + iconSize: 18, + onPressed: locked || off || !c.canEdit(s) + ? null + : () => openSubtitleEditor(context, c, v, s), + icon: Icon(Icons.edit_note, color: JjColors.accent), + ), + if (c.services.createTranslator != null) + IconButton( + tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'), + iconSize: 18, + onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), + icon: Icon(Icons.translate, color: JjColors.accent), + ), + IconButton( + tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'), + iconSize: 18, + onPressed: locked ? null : () => c.removeSubtitle(v, s), + icon: Icon( + embedded && off ? Icons.undo : Icons.delete_outline, + color: JjColors.textDim, + ), + ), + ]; + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: JjColors.panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: JjColors.border), + ), + child: LayoutBuilder(builder: (context, box) { + final fits = box.maxWidth >= _oneLineWidth(context, embedded: embedded, buttons: buttons.length); + if (fits) { + // 넓을 때: 지금처럼 한 줄 + return Row(children: [ + badge, + const SizedBox(width: 10), + Expanded(child: name), + const SizedBox(width: 8), + for (final (i, x) in pickers.indexed) ...[if (i > 0) const SizedBox(width: 8), x], + const SizedBox(width: 4), + ...buttons, + ]); + } + // 좁아 넘칠 때만 두 줄: 위 = 내장 표 · 트랙 이름, 아래 = 언어 고르기 · 단추 (언어 이름은 줄이지 않는다 - 무엇을 골랐는지 보이게) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [badge, const SizedBox(width: 10), Expanded(child: name)]), + const SizedBox(height: 2), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [...pickers, Row(mainAxisSize: MainAxisSize.min, children: buttons)], + ), + ]); + }), + ); + } +} + +class LanguageDropdown extends StatelessWidget { + final Language value; + final ValueChanged? onChanged; + const LanguageDropdown({super.key, required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final items = [undetermined, ...languages]; + return DropdownButton( + value: items.contains(value) ? value : undetermined, + isDense: true, + underline: const SizedBox(), + style: TextStyle(fontSize: 12, color: JjColors.text), + items: [ + for (final l in items) + DropdownMenuItem( + value: l, + child: Text(l == undetermined ? tr('언어: 미지정') : '${l.name} (${l.code})'), + ), + ], + onChanged: onChanged == null ? null : (l) => onChanged!(l!), + ); + } +} + +class CharsetDropdown extends StatelessWidget { + final String value; + final ValueChanged? onChanged; + const CharsetDropdown({super.key, required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) => DropdownButton( + value: supportedCharsets.containsKey(value) ? value : 'UTF-8', + isDense: true, + underline: const SizedBox(), + style: TextStyle(fontSize: 12, color: JjColors.text), + items: [ + for (final e in supportedCharsets.entries) + DropdownMenuItem(value: e.key, child: Text(e.value)), + ], + onChanged: onChanged == null ? null : (v) => onChanged!(v!), + ); +} + +// ───────── 아래: 작업 기록 ───────── diff --git a/lib/ui/home/video_detail.dart b/lib/ui/home/video_detail.dart new file mode 100644 index 0000000..7f9c385 --- /dev/null +++ b/lib/ui/home/video_detail.dart @@ -0,0 +1,230 @@ +/// 약속: 고른 동영상의 자세히 (정보 · 자막 목록 · 이동 버튼) - 바꾸기는 AppController 가. +library; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../core/languages.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../ai_dialog.dart'; +import '../app_actions.dart'; +import '../../app/settings.dart' show MoveTarget; +import '../player_page.dart'; +import '../subtitle_search_dialog.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import 'move_to_target.dart'; +import 'subtitle_row.dart'; + +class VideoDetailPane extends StatelessWidget { + final AppController c; + final VideoItem v; + const VideoDetailPane({super.key, required this.c, required this.v}); + + @override + Widget build(BuildContext context) { + final info = v.info; + final video = info?.ofType('video').firstOrNull; + final audioCount = info?.ofType('audio').length ?? 0; + // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) + final locked = v.status == JobStatus.running; + + // 오른쪽 아래 이동 버튼들 (환경 설정 > MKV 만들기 > 이동 버튼): 표시 이름으로, 위로 하나씩. + // 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상을 그 버튼의 폴더로 옮긴다. + final moving = c.checked.isEmpty ? [v] : c.batchTargets; + final canMove = moving.any((x) => x.status != JobStatus.running); + final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; + final buttons = c.settings.mkv.moveTargets; + Widget button(MoveTarget? t) => Tooltip( + message: t == null + ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') + : c.checked.isEmpty + ? trf('{0}: 이 동영상을 {1} 로 옮깁니다', [t.name, t.dir]) + : trf('{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다', [t.name, c.checked.length, t.dir]), + child: FloatingActionButton.extended( + heroTag: null, + onPressed: canMove ? () => moveToTarget(context, c, moving, target: t) : null, + icon: const Icon(Icons.drive_file_move_outline), + label: Text('${t?.name ?? tr('이동')}$count'), + ), + ); + // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 + // F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다. + // 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다) + if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) { + final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ + if (buttons.isEmpty) button(null), + for (final t in buttons) button(t), + ]); + return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24); + } + // 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼) + final stacked = buttons.isEmpty ? 1 : buttons.length; + return Stack(children: [ + _list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16), + Positioned( + right: 16, + bottom: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (buttons.isEmpty) button(null), + // 첫 번째 버튼이 맨 아래 (예전 [이동] 자리), 다음 버튼은 그 위로 + for (var i = buttons.length - 1; i >= 0; i--) ...[ + button(buttons[i]), + if (i > 0) const SizedBox(height: 10), + ], + ], + ), + ), + ]); + } + + Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked, + {Widget? moveButtons, double bottom = 88}) { + final compact = isCompact(context); + final side = compact ? 12.0 : 20.0; + // 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게 + final detected = v.aiDetected == null + ? null + : Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ + Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + TextButton( + onPressed: c.busy + ? null + : () async { + final lang = await pickSourceLanguage(context, languageOf(v.aiDetected)); + if (lang != null) await c.generateAiSubtitles([v], c.aiOptions.copyWith(source: lang)); + }, + child: Text(tr('다른 언어로 다시')), + ), + ]); + // 자막 머리 줄: 좁으면 버튼이 다음 줄로 (오른쪽 정렬) + final subtitleActions = Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 16), + Expanded( + child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ + if (c.aiAvailable) + FilledButton.icon( + onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), + icon: const Icon(Icons.auto_awesome, size: 18), + label: Text(tr('AI 자막 만들기')), + ), + if (c.subtitleProvider != null) + OutlinedButton.icon( + onPressed: locked ? null : () => showSubtitleSearch(context, c, v), + icon: const Icon(Icons.travel_explore, size: 18), + label: Text(tr('인터넷 자막 찾기')), + ), + OutlinedButton.icon( + onPressed: locked ? null : () => c.pickSubtitlesFor(v), + icon: const Icon(Icons.subtitles_outlined, size: 18), + label: Text(tr('자막 파일 추가')), + ), + ]), + ), + ]); + return ListView( + padding: EdgeInsets.fromLTRB(side, side, side, bottom), + children: [ + Row(children: [ + Expanded( + child: Text(v.fileName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ), + if (c.services.createMediaPlayer != null) ...[ + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () => playFiles(context, c, [v.path]), + icon: const Icon(Icons.play_arrow, size: 18), + label: Text(tr('재생')), + ), + ], + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => c.services.shell.revealFile(v.path), + icon: const Icon(Icons.folder_open, size: 18), + label: Text(tr('폴더 열기')), + ), + ]), + const SizedBox(height: 4), + SelectableText(v.path, + style: TextStyle(color: JjColors.textDim, fontSize: 12)), + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: [ + if (info?.duration != null) InfoChip(trf('길이 {0}', [_fmt(info!.duration!)])), + if (video != null) ...[ + if (video.width != null) InfoChip('${video.width}×${video.height}'), + InfoChip(trf('영상 {0}', [video.codec])), + ], + InfoChip(trf('음성 {0}개', [audioCount])), + ]), + // 261: 좁은 화면 (폰 세로) 은 세부 정보 칸이 낮다 (노트 10+ 에서 약 210) - "감지한 언어" 줄 · 이동 버튼이 + // [AI 자막 만들기] 를 칸 밖으로 밀었다. 자막 버튼을 칩 바로 아래에 두고, 감지한 언어 · 이동 버튼은 그 다음 + if (compact) ...[ + const SizedBox(height: 12), + subtitleActions, + if (detected != null) ...[const SizedBox(height: 4), detected], + if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], + ] else ...[ + if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], + if (detected != null) ...[const SizedBox(height: 8), detected], + ], + if (v.message != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: JjColors.danger.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: SelectableText(v.message!, + style: TextStyle(color: JjColors.danger, fontSize: 12)), + ), + ], + if (!compact) ...[ + const SizedBox(height: 24), + subtitleActions, + ], + const SizedBox(height: 8), + if (v.subtitles.isEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text(tr('자막이 없습니다.'), style: TextStyle(color: JjColors.textDim)), + ), + for (final s in v.subtitles) + SubtitleRow(c: c, v: v, s: s, locked: locked), + const SizedBox(height: 24), + Text(trf('출력: {0}', [outputMkvPath(v.path)]), + style: TextStyle(color: JjColors.textDim, fontSize: 12)), + ], + ); + } + + static String _fmt(Duration d) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; + } +} + +class InfoChip extends StatelessWidget { + final String text; + const InfoChip(this.text, {super.key}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: JjColors.panelHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Text(text, style: const TextStyle(fontSize: 12)), + ); +} diff --git a/lib/ui/home/video_list_view.dart b/lib/ui/home/video_list_view.dart new file mode 100644 index 0000000..a817a64 --- /dev/null +++ b/lib/ui/home/video_list_view.dart @@ -0,0 +1,319 @@ +/// 약속: MKV 목록 (정렬 · 체크 · 상태 · 줄 메뉴 · 세 번 누르기) - 바꾸기는 AppController 가. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show HapticFeedback; + +import '../../app/app_controller.dart'; +import '../../core/models.dart'; +import '../ai_dialog.dart'; +import '../player_page.dart'; +import '../subtitle_search_dialog.dart'; +import '../confirm.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import 'move_to_target.dart'; + +class HomeVideoList extends StatelessWidget { + final AppController c; + + /// 좁은 화면 '자동': 줄이 적으면 내용 높이만 (빈 칸을 남기지 않게) + final bool shrink; + const HomeVideoList({super.key, required this.c, this.shrink = false}); + + /// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표 + Widget _sortButton(String by, IconData icon, String name) { + final on = c.sortedBy == by; + return IconButton( + tooltip: trf('{0} 순으로 정렬' '{1}', [name, on ? (c.sortAscending ? tr(' (지금: 오름차순 · 다시 누르면 내림차순)') : tr(' (지금: 내림차순 · 다시 누르면 오름차순)')) : '']), + iconSize: 18, + visualDensity: VisualDensity.compact, + onPressed: c.videos.length < 2 ? null : () => c.sortVideos(by), + icon: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, color: on ? JjColors.accent : JjColors.textDim), + if (on) + Icon(c.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: JjColors.accent), + ]), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + color: JjColors.panel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: shrink ? MainAxisSize.min : MainAxisSize.max, + children: [ + // 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기 + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 4, 2), + child: Row(children: [ + Checkbox( + visualDensity: VisualDensity.compact, + tristate: true, + value: c.videos.isEmpty || c.checked.isEmpty + ? false + : (c.checked.length == c.videos.length ? true : null), + onChanged: c.videos.isEmpty ? null : (_) => c.toggleAllChecked(), + ), + Expanded( + child: InkWell( + onTap: c.videos.isEmpty ? null : c.toggleAllChecked, + // 좁으면 글자를 줄여서 한 줄에 다 보이게 + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + c.checked.isEmpty + ? trf('동영상 {0}개 · 전체 선택', [c.videos.length]) + : trf('선택 {0} / {1}개', [c.checked.length, c.videos.length]), + maxLines: 1, + style: TextStyle(color: JjColors.textDim, fontSize: 12)), + ), + ), + ), + // 정렬: 같은 것을 다시 누르면 반대 순서 + _sortButton('name', Icons.sort_by_alpha, tr('파일 이름')), + _sortButton('date', Icons.calendar_month, tr('날짜')), + if (c.checked.isNotEmpty) + IconButton( + tooltip: tr('선택한 동영상을 목록에서 제거'), + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: Icon(Icons.playlist_remove, color: JjColors.textDim), + onPressed: c.removeChecked, + ), + IconButton( + tooltip: tr('모두 지우기'), + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: Icon(Icons.clear_all, color: JjColors.textDim), + // 46: 확인 뒤 (목록에서만 빼고 파일은 그대로) + onPressed: c.busy || c.videos.isEmpty + ? null + : () async { + final ok = await confirmAction( + context, + title: tr('목록을 모두 비울까요?'), + body: trf('동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.', [c.videos.length]), + ok: tr('모두 지우기'), + ); + if (ok) c.clearVideos(); + }, + ), + ]), + ), + Flexible( + fit: shrink ? FlexFit.loose : FlexFit.tight, + child: ListView.builder( + shrinkWrap: shrink, + itemCount: c.videos.length, + itemBuilder: (_, i) => VideoTile(c: c, v: c.videos[i]), + ), + ), + ], + ), + ); + } +} + +class VideoTile extends StatelessWidget { + final AppController c; + final VideoItem v; + const VideoTile({super.key, required this.c, required this.v}); + + Future _menu(BuildContext context, Offset global) async { + c.select(v); + final busy = v.status == JobStatus.running; + PopupMenuItem item(String id, IconData icon, String label, {bool enabled = true}) => PopupMenuItem( + value: id, + enabled: enabled, + height: 36, + child: Row(children: [ + Icon(icon, size: 18, color: enabled ? JjColors.text : JjColors.textDim), + const SizedBox(width: 10), + Text(label, style: const TextStyle(fontSize: 13)), + ]), + ); + // 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 바꾼다 + final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; + final at = overlay.globalToLocal(global); + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy), + items: [ + item('folder', Icons.folder_open, tr('대상 폴더 열기')), + if (c.services.createMediaPlayer != null) item('play', Icons.play_arrow, tr('재생')), + const PopupMenuDivider(), + if (c.aiAvailable) + item('ai', Icons.auto_awesome, tr('AI 자막 만들기'), enabled: !busy && c.ffmpegVersion != null), + if (c.subtitleProvider != null) + item('search', Icons.travel_explore, tr('인터넷 자막 찾기'), enabled: !busy), + item('subtitle', Icons.subtitles_outlined, tr('자막 파일 추가'), enabled: !busy), + const PopupMenuDivider(), + item('remove', Icons.close, tr('목록에서 빼기 (파일은 그대로)'), enabled: !busy), + ], + ); + if (pick == null || !context.mounted) return; + switch (pick) { + case 'folder': + await c.services.shell.revealFile(v.path); + case 'play': + await playFiles(context, c, [v.path]); + case 'ai': + await showAiDialog(context, c, v, targets: [v]); + case 'search': + await showSubtitleSearch(context, c, v); + case 'subtitle': + await c.pickSubtitlesFor(v); + case 'remove': + c.removeVideo(v); + } + } + + // 연달아 누른 횟수 (두 번이면 재생, 세 번 이상이면 이동). 마지막으로 누르고 잠시 뒤에 정한다. + static VideoItem? _tapItem; + static int _taps = 0; + static Timer? _tapTimer; + + void _tapped(BuildContext context) { + c.select(v); + _taps = _tapItem == v ? _taps + 1 : 1; + _tapItem = v; + _tapTimer?.cancel(); + _tapTimer = Timer(const Duration(milliseconds: 350), () { + final n = _taps; + _taps = 0; + _tapItem = null; + if (!context.mounted) return; + if (n == 2) { + playFiles(context, c, [v.path]); + } else if (n >= 3 && c.settings.mkv.tripleTapAction == 'move') { + // 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다 + moveToTarget(context, c, [v], confirm: true); + } + }); + } + + @override + Widget build(BuildContext context) { + final isSel = c.selected == v; + final subs = v.subtitles.where((s) => s.enabled).length; + // 51: 터치 화면에서는 길게 누르면 오른쪽 클릭과 같은 메뉴 (누른 자리에) + return GestureDetector( + onLongPressStart: (d) { + HapticFeedback.selectionClick(); + _menu(context, d.globalPosition); + }, + child: InkWell( + // 한 번: 보기 · 두 번: 재생 · 세 번 이상: 이동 폴더로 옮기기 + onTap: () => _tapped(context), + // 오른쪽 클릭: 이 동영상으로 할 수 있는 일 + onSecondaryTapDown: (d) => _menu(context, d.globalPosition), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.fromLTRB(2, 10, 4, 10), + decoration: BoxDecoration( + color: isSel ? JjColors.panelHigh : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSel ? JjColors.accent.withValues(alpha: 0.6) : Colors.transparent), + ), + child: Row( + children: [ + // 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기) + Checkbox( + visualDensity: VisualDensity.compact, + value: c.checked.contains(v), + onChanged: (_) => c.toggleChecked(v), + ), + JobStatusIcon(v.status), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(v.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13)), + const SizedBox(height: 4), + if (v.status == JobStatus.running) ...[ + LinearProgressIndicator(value: v.progress, minHeight: 3), + if (v.phase != null) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text(v.phase!, + style: TextStyle(fontSize: 11, color: JjColors.accent)), + ), + ] else if (v.phase != null) + Text(v.phase!, style: TextStyle(fontSize: 11, color: JjColors.textDim)) + else + Text( + v.status == JobStatus.failed + ? tr('실패') + : v.status == JobStatus.cancelled + ? tr('취소됨') + : trf('자막 {0}개', [subs]), + style: TextStyle( + fontSize: 11, + color: v.status == JobStatus.failed + ? JjColors.danger + : JjColors.textDim), + ), + ], + ), + ), + if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled) + // 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게) + IconButton( + tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'), + iconSize: 16, + onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v), + icon: Icon(Icons.refresh, color: JjColors.textDim), + ), + // 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로) + if (v.status == JobStatus.running && c.buildingMkv) + IconButton( + tooltip: tr('이 동영상만 취소'), + iconSize: 16, + onPressed: () => c.cancelVideos([v]), + icon: Icon(Icons.stop, color: JjColors.danger), + ), + IconButton( + tooltip: tr('목록에서 제거'), + iconSize: 16, + onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v), + icon: Icon(Icons.close, color: JjColors.textDim), + ), + ], + ), + ), + )); + } +} + +class JobStatusIcon extends StatelessWidget { + final JobStatus s; + const JobStatusIcon(this.s, {super.key}); + + @override + Widget build(BuildContext context) => switch (s) { + JobStatus.ready => + Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim), + JobStatus.running => + Icon(Icons.autorenew, size: 20, color: JjColors.accent), + JobStatus.done => + Icon(Icons.check_circle, size: 20, color: JjColors.success), + JobStatus.failed => + Icon(Icons.error, size: 20, color: JjColors.danger), + // 187: 취소는 실패가 아니라 회색 + JobStatus.cancelled => + Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim), + }; +} + +// ───────── 오른쪽: 선택한 동영상 ───────── diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 40dae7d..ddbe298 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1,39 +1,20 @@ import 'dart:math' as math; -import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show HapticFeedback; import '../app/app_controller.dart'; import '../app/bookmarks_controller.dart'; import '../app/download_manager.dart'; -import '../core/charset_detector.dart'; -import '../core/encode_options.dart'; -import '../core/languages.dart'; -import '../core/models.dart'; -import '../core/output_paths.dart'; -import 'ai_dialog.dart'; import 'app_actions.dart'; -import 'scroll_more.dart'; -import 'downloads_page.dart'; -import 'existing_output_dialog.dart'; import 'exit_guard.dart'; -import 'package:path/path.dart' as p; - -import '../app/settings.dart' show MoveTarget; -import 'folder_picker.dart'; import 'network_settings.dart' show DnsFailureLine; -import 'player_page.dart'; -import 'subtitle_editor_page.dart'; -import 'subtitle_search_dialog.dart'; -import 'confirm.dart'; -import 'encode_notice.dart'; import 'theme.dart'; -import 'translate_dialog.dart'; -import 'video_adjust_dialog.dart'; -import 'work_panel.dart'; import '../l10n/tr.dart'; +import 'home/log_panel.dart'; +import 'home/video_detail.dart'; +import 'home/video_list_view.dart'; +import 'home/encode_bar.dart'; +import 'home/home_top_bar.dart'; class HomePage extends StatelessWidget { final AppController c; @@ -62,9 +43,9 @@ class HomePage extends StatelessWidget { current: 'mkv', child: Column( children: [ - _TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks), + HomeTopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks), const Divider(height: 1), - _EncodeBar(c: c), + EncodeBar(c: c), const Divider(height: 1), Expanded( // 좁은 화면 (접은 폴드 · 휴대폰 세로): 목록을 위에, 자세히 보기를 아래에 @@ -79,12 +60,12 @@ class HomePage extends StatelessWidget { // 13: 큰 화면 (태블릿 세로 등) 은 목록도 넓게 - 제목이 앞부분만 보여 구분이 안 되던 것 SizedBox( width: (MediaQuery.sizeOf(context).width * 0.4).clamp(320.0, 640.0), - child: _VideoList(c: c)), + child: HomeVideoList(c: c)), const VerticalDivider(width: 1), Expanded( // 목록이 비었으면 [동영상 추가] 안내, 동영상은 있는데 고른 것이 없으면 "목록에서 누르세요" child: c.selected != null - ? _VideoDetail(c: c, v: c.selected!) + ? VideoDetailPane(c: c, v: c.selected!) : c.videos.isEmpty ? _EmptyHint(onAdd: c.pickVideos) : const _PickHint(), @@ -101,17 +82,17 @@ class HomePage extends StatelessWidget { // 256: 폰 가로 (높이 500 미만) 는 더 작게 - 목록이 한 줄만 보였다 // 관리자 판단 (10/17, 15 지적): 폰 세로에서 기록이 비었으면 안내 한 줄 높이만 (빈 자리만 없앰), 기록이 생기면 예전 높이 height: isCompact(context) && c.logs.isEmpty - ? _LogPanel.emptyHeight + ? LogPanel.emptyHeight : MediaQuery.sizeOf(context).height < 500 ? 52 : (MediaQuery.sizeOf(context).height * 0.22).clamp(80.0, 140.0), - child: _LogPanel( + child: LogPanel( logs: c.logs, onClose: () => c.updateSettings((s) => s.display.showLog = false), ), ) else - _LogStrip( + LogStrip( last: c.logs.isEmpty ? '' : c.logs.last, onOpen: () => c.updateSettings((s) => s.display.showLog = true), ), @@ -126,868 +107,6 @@ class HomePage extends StatelessWidget { // ───────── 상단 바 ───────── -class _TopBar extends StatelessWidget { - final AppController c; - final DownloadManager? downloads; - final VoidCallback? onExit; - final BookmarksController? bookmarks; - const _TopBar({required this.c, this.downloads, this.onExit, this.bookmarks}); - - /// 고른 동영상이 없을 때: 목록 전체를 만들지 묻는다 - /// 보고 있는 동영상의 결과 폴더 (jj_mkv) 를 탐색기 · 파일 앱으로. 아직 없으면 알린다. - /// 45: 대기 중인 작업이 있으면 지금 작업만 / 모두 고르게 한다 (없으면 지금 작업을 바로 멈춤 - 예전처럼) - /// 186: 한 묶음 안의 차례를 기다리는 동영상 (MKV 만들기 3개 중 v2 · v3) 도 대기로 센다 - 남은 것이 있으면 - /// "지금 것만 취소 · 남은 것 모두 취소 · 그대로 두기" 를 묻는다 (예전에는 묻지 않고 모두 멈췄다) - Future _cancelJobs(BuildContext context) async { - final inBatch = c.batchWaiting; - final waiting = c.pendingJobs.length + inBatch; - // 239: 창을 연 때 돌고 있던 것 - [지금 것만 취소] 는 이것만 (그사이 끝나고 다음 것이 시작해도 다음 것은 취소하지 않는다) - final targets = c.buildingMkv ? c.runningVideos : const []; - if (waiting == 0) { - // 240: 여럿이 동시에 돌고 있으면 묻는다 (하나씩은 목록 줄의 ■) - if (targets.length > 1) { - final all = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - actionsOverflowDirection: VerticalDirection.down, - title: Text(tr('작업 취소')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])), - const SizedBox(height: 4), - Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.danger), - onPressed: () => Navigator.pop(ctx, true), - child: Text(trf('모두 취소 ({0}개)', [targets.length])), - ), - ], - ), - ); - if (all == true) c.cancel(); - return; - } - c.cancel(); - return; - } - if (inBatch > 0) { - final now = targets.map((v) => v.fileName).join(', '); - final pick = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - actionsOverflowDirection: VerticalDirection.down, // 110: [그대로 두기] 가 먼저 - title: Text(tr('작업 취소')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf('지금: {0}', [now.isEmpty ? (c.currentJob ?? '') : now])), - const SizedBox(height: 4), - Text(trf('남은 것: MKV 만들기 {0}개{1}', [inBatch, c.pendingJobs.isEmpty ? '' : ' · ${c.pendingJobs.join(' · ')}']), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), - TextButton(onPressed: () => Navigator.pop(ctx, 'current'), child: Text(tr('지금 것만 취소 (다음 것부터 이어서)'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.danger), - onPressed: () => Navigator.pop(ctx, 'all'), - child: Text(trf('남은 것 모두 취소 ({0}개)', [waiting])), - ), - ], - ), - ); - if (pick == 'current') { - final gone = c.cancelVideos(targets); - // 239: 창을 보는 사이 이미 끝났으면 알리고 지금 상태로 다시 묻는다 - if (gone.isNotEmpty && context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(trf('{0} 는 이미 끝났습니다', [gone.map((v) => v.fileName).join(', ')])))); - if (c.busy) await _cancelJobs(context); - } - } - if (pick == 'all') c.cancel(); - return; - } - final pick = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('작업 취소')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf('지금: {0}', [c.currentJob ?? ''])), - const SizedBox(height: 4), - Text(trf('대기 중: {0}', [c.pendingJobs.join(' · ')]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), - TextButton(onPressed: () => Navigator.pop(ctx, 'current'), child: Text(trf('지금 작업만 (대기 {0}개는 계속)', [waiting]))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.danger), - onPressed: () => Navigator.pop(ctx, 'all'), - child: Text(trf('모두 취소 (대기 {0}개 포함)', [waiting])), - ), - ], - ), - ); - if (pick == 'current') c.cancelCurrent(); - if (pick == 'all') c.cancel(); - } - - Future _openOutput(BuildContext context) async { - final v = c.selected; - if (v == null) return; - final dir = outputDirFor(v.path); - if (!await Directory(dir).exists()) { - if (context.mounted) { - ScaffoldMessenger.maybeOf(context) - ?.showSnackBar(SnackBar(content: Text(trf('아직 만든 MKV · 자막이 없습니다: {0}', [dir])))); - } - return; - } - await c.services.shell.revealFile(dir); - } - - Future _confirmBuildAll(BuildContext context) async { - final todo = c.videos.where((v) => v.status != JobStatus.done).length; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('MKV 만들기')), - content: Text(trf('선택한 동영상이 없습니다.\n목록 전체 {0}개 ' '(이미 만든 것을 빼면 {1}개) 를 MKV 로 만들까요?\n\n' '일부만 만들려면 취소하고 동영상 목록에서 체크하세요.', [c.videos.length, todo])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('전체 만들기'))), - ], - ), - ); - // P0: 결과 MKV 가 이미 있으면 먼저 묻는다 - if (ok == true && context.mounted) await buildAllAsking(context, c); - } - - static String _batchHint(AppController c, String what) => c.checked.isEmpty - ? trf('지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)', [what]) - : trf('체크한 동영상 {0}개의 {1}', [c.checked.length, what]); - - @override - Widget build(BuildContext context) { - final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null; - // 일괄 작업 대상: 체크한 동영상, 없으면 지금 보고 있는 한 개 - final batch = c.batchTargets; - final canBatch = batch.isNotEmpty && c.ffmpegVersion != null; - final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; - // 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김. - // (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게) - final compact = isCompact(context); - return LayoutBuilder(builder: (context, box) { - final hasAi = c.aiAvailable; - final hasPlayer = c.services.createMediaPlayer != null; - var title = true, leftText = true, rightText = true, usage = c.usage != null, jobText = true; - // 아주 좁을 때: 다운로드 상자 숨김 (왼쪽 공통 버튼으로 열 수 있음) → MKV 만들기도 아이콘만 - var dlBox = downloads != null, mkvText = true; - // 각 부분의 너비 (실제 화면에서 잰 값 - 한국어 글 기준) - // 61: 다른 언어는 버튼 글이 더 길 수 있어 (일본어 "字幕作成 & MKV 作成") 그 차이를 재어 더한다 - 안 하면 줄이기 전에 잘렸다 - final style = Theme.of(context).textTheme.labelLarge ?? const TextStyle(fontSize: 14); - double textW(String s) => (TextPainter(text: TextSpan(text: s, style: style), textDirection: TextDirection.ltr, maxLines: 1) - ..layout()) - .width; - double more(String ko) => uiLanguage == 'ko' ? 0 : math.max(0, textW(tr(ko).replaceAll('{0}', '').replaceAll('{1}', '')) - textW(ko.replaceAll('{0}', '').replaceAll('{1}', ''))); - final leftMore = more('동영상 추가') + (hasAi ? more('자막 만들기{0}') + more('자막 만들기 & MKV 만들기{0}') : 0); - final playMore = more('선택한 파일 재생{0}'), mkvMore = more('MKV 만들기{0}{1}'), outMore = more('결과 폴더'); - // 좁은 화면: 이동 버튼 · 설정 · 종료는 첫 줄에 있으므로 둘째 줄 (가운데 도구) 만 잰다 - double need() => - (compact ? 0 : 8 + appBarRightPadding + AppNavButtons.width + 8) + (title ? 126 : 0) + 24 + - (leftText ? 153 + (hasAi ? 161 + 298 : 0) + leftMore : 40 + (hasAi ? 96 : 0)) + 8 + - (c.busy ? (jobText ? 350 : 60) : 0) + - (hasPlayer ? (rightText ? 201 + playMore : 48) : 0) + - (mkvText ? 166 + mkvMore + (c.busy && jobText ? 60 : 0) : 48) + - (rightText ? 166 + outMore : 48) + // 결과 폴더 - (dlBox ? 162 : 0) + - (compact ? 0 : (usage ? 220 : 0) + 96 + 92) + - (c.checked.isEmpty ? 0 : 3 * 26) + - 8; // 여유 - final w = box.maxWidth; - if (compact) title = false; - if (need() > w) title = false; - if (need() > w) jobText = false; - if (need() > w) leftText = false; - if (need() > w) rightText = false; - if (need() > w) usage = false; - if (need() > w) dlBox = false; - if (need() > w) mkvText = false; - // 242: 다 줄여도 모자라는지는 어림 (need) 이 아니라 위 막대가 실제로 잰다 (오른쪽 묶음 [trailing] · minMiddleWidth) - - // 7: 폰에는 "탐색기에서 끌어다 놓기" 가 없다 - final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); - - // 글이 있는 버튼, 또는 (좁을 때) 아이콘만 있는 버튼 - // 138: [ai] 는 AI 가 들어가는 버튼 (보라 테두리 · 글자) - 청록 [MKV 만들기] 와 한눈에 구별되게 - Widget action(IconData icon, String label, VoidCallback? onPressed, - {required bool text, required String tip, Color? color, bool ai = false}) { - final fg = ai ? aiColor : null; - final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border); - // 241: 아이콘만 있는 버튼은 이름을 버튼 자체에 (바깥 Tooltip 은 TalkBack 이 읽는 이름이 되지 않았다) - if (!text) { - return IconButton.outlined( - tooltip: tip, - onPressed: onPressed, - icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg), - style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)); - } - return Tooltip( - message: tip, - child: OutlinedButton.icon( - style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, - onPressed: onPressed, - icon: Icon(icon, size: 18, color: color ?? fg), - label: Text(label)), - ); - } - - // AI 버튼 둘 (자막 만들기 · 자막 만들기 & MKV 만들기) - List aiButtons(bool text) => [ - action(Icons.auto_awesome, trf('자막 만들기{0}', [count]), - canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, - text: text, ai: true, tip: trf('자막 만들기: {0}', [_batchHint(c, tr('AI 자막을 만듭니다'))])), - const SizedBox(width: 8), - action(Icons.auto_awesome_motion, trf('자막 만들기 & MKV 만들기{0}', [count]), - canBatch ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) : null, - text: text, - ai: true, - tip: trf('자막 만들기 & MKV 만들기: {0}', [_batchHint(c, tr('AI 자막을 만들고 이어서 MKV 로 만듭니다'))])), - ]; - - // 2: 좁은 화면 (폰 세로) 에서는 버튼 글자를 숨기지 않고 옆으로 밀어 본다. 가장 많이 쓰는 [MKV 만들기] 를 맨 앞에. - if (compact) { - VoidCallback? build0() => - !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)); - return AppTopBar( - nav: const AppNavButtons(onMkvPage: true), - actions: AppActions(c: c, onExit: onExit, showUsage: false), - // 15 지적: 폭 412 에서 세 번째 버튼이 잘렸는데 더 있다는 표시가 없었다 - 끝에 흐림 · [›] - middle: ScrollMore( - background: JjColors.panel, - child: Row(children: [ - Tooltip( - message: trf('MKV 만들기{0}', [count]), - child: FilledButton.icon( - onPressed: build0(), - icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), - label: Text(trf('MKV 만들기{0}{1}', [count, c.busy ? tr(' (대기열)') : ''])), - ), - ), - const SizedBox(width: 8), - action(Icons.add, tr('동영상 추가'), c.pickVideos, text: true, tip: addTip), - if (hasPlayer) ...[ - const SizedBox(width: 8), - action( - Icons.playlist_play, - trf('선택한 파일 재생{0}', [count]), - batch.isEmpty ? null : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), - text: true, - tip: tr('선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)')), - ], - const SizedBox(width: 8), - action(Icons.folder_special_outlined, tr('결과 폴더'), c.selected == null ? null : () => _openOutput(context), - text: true, tip: tr('결과 폴더 열기')), - // 138: AI 버튼은 [MKV 만들기] 와 떨어뜨려 맨 뒤에, 구분선 너머로 - if (hasAi) ...[ - const SizedBox(width: 8), - SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)), - ...aiButtons(true), - ], - ]), - ), - // P0-4: 작업 중에는 진행 · [취소] 를 줄 끝 (늘 보이는 곳) 에 - 예전에는 옆으로 미는 줄의 맨 끝이라 두 번 밀어야 보였다 - trailing: c.busy - ? Row(mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 6), - JobIndicator(c: c, compact: true, iconOnly: true), - const SizedBox(width: 4), - action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), - ]) - : null, - ); - } - - return AppTopBar( - // 242: 작업 중 (진행 표시 · 취소) 이 생기거나 없어지면 다시 잰다 - // (여러 개를 만드는지에 따라 진행 표시 폭이 달라 그때도 다시 잰다) - key: ValueKey(('mkvTopBar', c.busy, c.batchTotal > 1)), - // 242: 오른쪽 묶음 때문에 왼쪽 (동영상 추가 · AI) 이 아이콘 하나보다 좁아지면 두 줄 - minMiddleWidth: 56, - // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] - nav: const AppNavButtons(onMkvPage: true), - // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 - actions: AppActions(c: c, onExit: onExit, showUsage: usage), - middle: Row( - children: [ - if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), - SizedBox(width: compact ? 0 : 24), - // 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게 - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - action(Icons.add, tr('동영상 추가'), c.pickVideos, - text: leftText, tip: addTip), - // 138: AI 버튼 (보라) 은 왼쪽, [MKV 만들기] (청록) 는 오른쪽 - 떨어져 있다 - if (hasAi) ...[ - const SizedBox(width: 8), - ...aiButtons(leftText), - ], - ]), - ), - ), - const SizedBox(width: 8), - ], - ), - // 오른쪽 묶음 (줄지 않음): 작업 · 재생 · MKV 만들기 · 결과 폴더 · 다운로드 - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (c.busy) ...[ - JobIndicator(c: c, compact: !jobText, iconOnly: !jobText), - const SizedBox(width: 8), - action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: jobText, tip: tr('작업 취소'), color: JjColors.danger), - const SizedBox(width: 8), - ], - // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) - if (hasPlayer) ...[ - action( - Icons.playlist_play, - trf('선택한 파일 재생{0}', [count]), - batch.isEmpty - ? null - : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), - text: rightText, - tip: c.checked.isEmpty - ? tr('선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)') - : trf('선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서', [c.checked.length])), - const SizedBox(width: 8), - ], - // 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다 - Tooltip( - // 241: 아이콘만일 때는 버튼 자체의 tooltip (이름) 을 쓰고 바깥은 비운다 (두 번 뜨지 않게) - message: mkvText ? trf('MKV 만들기{0}', [count]) : '', - child: mkvText - ? FilledButton.icon( - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), - icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), - label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), - ) - : IconButton.filled( - tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름 - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), - icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), - ), - ), - // 만든 MKV 가 있는 폴더 (jj_mkv). 웹 브라우저 버튼은 왼쪽 공통 버튼으로 옮김 - const SizedBox(width: 8), - action(Icons.folder_special_outlined, tr('결과 폴더'), c.selected == null ? null : () => _openOutput(context), - text: rightText, - tip: c.selected == null - ? tr('결과 폴더 열기 (동영상을 고르세요)') - : trf('결과 폴더 열기: {0}', [outputDirFor(c.selected!.path)])), - if (dlBox) ...[ - const SizedBox(width: 12), - _DownloadBox(d: downloads!), - ], - ], - ), - ); - }); - } -} - -/// 상단 "다운로딩" 상자: 누르면 다운로드 목록 -class _DownloadBox extends StatelessWidget { - final DownloadManager d; - const _DownloadBox({required this.d}); - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: d, - builder: (context, _) { - final n = d.downloadingCount; - final p = d.overallProgress; - return InkWell( - borderRadius: BorderRadius.circular(6), - onTap: () => DownloadsPage.open(Navigator.of(context), d), - child: Container( - width: 150, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - border: Border.all(color: n > 0 ? JjColors.accent : JjColors.border), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.downloading, size: 16, - color: n > 0 ? JjColors.accent : JjColors.textDim), - const SizedBox(width: 6), - Text( - n > 0 - ? trf('다운로딩 {0}{1}', [n, p == null ? '' : ' · ${(p * 100).toStringAsFixed(0)}%']) - : trf('다운로드 {0}', [d.tasks.length]), - style: const TextStyle(fontSize: 12)), - ]), - if (n > 0) ...[ - const SizedBox(height: 4), - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: p, minHeight: 4, backgroundColor: JjColors.panelHigh), - ), - ], - ], - ), - ), - ); - }, - ); -} - -// ───────── 화면 크기 · 코덱 · 화질 ───────── - -class _EncodeBar extends StatelessWidget { - final AppController c; - const _EncodeBar({required this.c}); - - @override - Widget build(BuildContext context) { - final s = c.encode; - // 32: MKV 를 만드는 동안만 (AI 자막 · 번역 중에는 다음 MKV 설정을 바꿀 수 있다) - final locked = c.buildingMkv; - final up = c.upscaleCount; - - Widget label(String t) => Padding( - padding: const EdgeInsets.only(right: 6), - child: Text(t, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ); - // 253: 글자가 크면 고르기 상자가 화면보다 넓어져 넘쳤다 - 제 폭 (가장 긴 항목) 을 쓰되 화면 폭을 넘지 않게 (글은 …) - final maxDrop = (MediaQuery.sizeOf(context).width - 40).clamp(120.0, double.infinity); - Widget drop(T value, List items, String Function(T) text, - ValueChanged onChanged, {bool Function(T)? enabled}) => - ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxDrop), - child: IntrinsicWidth( - child: DropdownButton( - value: value, - isDense: true, - isExpanded: true, - underline: const SizedBox(), - style: TextStyle(fontSize: 13, color: JjColors.text), - items: [ - for (final i in items) - DropdownMenuItem( - value: i, - enabled: enabled?.call(i) ?? true, - child: Text( - text(i) + ((enabled?.call(i) ?? true) ? '' : tr(' (사용 불가)')), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: (enabled?.call(i) ?? true) ? null : JjColors.textDim), - ), - ), - ], - onChanged: locked ? null : (v) => onChanged(v as T), - ), - ), - ); - - final compact = isCompact(context); - final row = Row(children: [ - label(tr('화면 크기')), - drop(s.resolution, ResolutionChoice.values, (r) => r.label, (r) { - c.setResolution(r); - showEncodeNotice(context, c); - }), - const SizedBox(width: 20), - label(tr('코덱')), - drop(s.codec, VideoCodecChoice.values, (v) => v.label, c.setCodec, - enabled: c.isCodecAvailable), - const SizedBox(width: 20), - if (s.reencode) ...[ - label(tr('화질')), - drop(s.quality, QualityChoice.values, (q) => q.label, c.setQuality), - const SizedBox(width: 20), - ], - // 화면 비율 (가로 · 세로) · 회전 · 색 보정: 미리보기를 보며 고르는 창 - Tooltip( - message: s.adjusts ? '${tr('화면 · 색 보정')}: ${s.adjustSummary}' : tr('화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도'), - child: (s.adjusts ? FilledButton.tonalIcon : OutlinedButton.icon)( - onPressed: locked ? null : () => showVideoAdjust(context, c), - icon: const Icon(Icons.tune, size: 18), - label: Text(tr('화면 · 색 보정')), - ), - ), - const SizedBox(width: 12), - Expanded( - // 31: "⚠ 원본보다 커서" 경고는 화면 너비와 상관없이 늘 보이게 맨 앞에 (넓은 화면에서 한 줄이 넘치면 설명 · 보정 요약 쪽이 잘린다) - child: Text( - '${s.reencode && up > 0 ? '${trf('⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다', [up])} · ' : ''}' - '${s.adjusts ? s.adjustSummary : !s.reencode ? tr('영상·음성을 그대로 복사합니다 (빠름, 화질 손실 없음)') : tr('영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로)')}', - overflow: compact ? null : TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim), - ), - ), - ]); - // 241 시험에서 찾음: 폭 720 ~ 840 (노트 10+ 가로 등) 에서 한 줄이 넘쳤다 - 960 보다 좁으면 폰처럼 줄을 바꿔 보인다 (내 판단) - // 256: 낮은 화면 (폰 가로) 은 여러 줄로 감기면 목록이 한 줄만 보였다 - 한 줄로 옆으로 밀게. - // 31: 설명 · "⚠ 원본보다 커서" 경고는 맨 앞에 (밀지 않아도 보이게) - if (!compact && MediaQuery.sizeOf(context).height < 500) { - final text = [for (final w in row.children) if (w is Expanded) w.child]; - return Container( - width: double.infinity, - height: 44, - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - for (final t in text) ...[ConstrainedBox(constraints: const BoxConstraints(maxWidth: 360), child: t), const SizedBox(width: 16)], - for (final w in row.children) if (w is! Expanded) w, - ]), - ), - ); - } - if (compact || MediaQuery.sizeOf(context).width < 960) { - return Container( - width: double.infinity, - color: JjColors.panel, - padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runSpacing: 6, - // 31: 설명 · "⚠ 원본보다 커서 용량만 늘어납니다" 경고는 빼지 않고 한 줄을 다 써서 보여 준다 - children: [ - for (final w in row.children) - w is Expanded ? SizedBox(width: double.infinity, child: w.child) : w, - ], - ), - ); - } - return Container( - height: 44, - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: row, - ); - } -} - -// ───────── 왼쪽: 동영상 목록 ───────── - -class _VideoList extends StatelessWidget { - final AppController c; - - /// 좁은 화면 '자동': 줄이 적으면 내용 높이만 (빈 칸을 남기지 않게) - final bool shrink; - const _VideoList({required this.c, this.shrink = false}); - - /// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표 - Widget _sortButton(String by, IconData icon, String name) { - final on = c.sortedBy == by; - return IconButton( - tooltip: trf('{0} 순으로 정렬' '{1}', [name, on ? (c.sortAscending ? tr(' (지금: 오름차순 · 다시 누르면 내림차순)') : tr(' (지금: 내림차순 · 다시 누르면 오름차순)')) : '']), - iconSize: 18, - visualDensity: VisualDensity.compact, - onPressed: c.videos.length < 2 ? null : () => c.sortVideos(by), - icon: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: on ? JjColors.accent : JjColors.textDim), - if (on) - Icon(c.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: JjColors.accent), - ]), - ); - } - - @override - Widget build(BuildContext context) { - return Container( - color: JjColors.panel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: shrink ? MainAxisSize.min : MainAxisSize.max, - children: [ - // 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기 - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 4, 2), - child: Row(children: [ - Checkbox( - visualDensity: VisualDensity.compact, - tristate: true, - value: c.videos.isEmpty || c.checked.isEmpty - ? false - : (c.checked.length == c.videos.length ? true : null), - onChanged: c.videos.isEmpty ? null : (_) => c.toggleAllChecked(), - ), - Expanded( - child: InkWell( - onTap: c.videos.isEmpty ? null : c.toggleAllChecked, - // 좁으면 글자를 줄여서 한 줄에 다 보이게 - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - c.checked.isEmpty - ? trf('동영상 {0}개 · 전체 선택', [c.videos.length]) - : trf('선택 {0} / {1}개', [c.checked.length, c.videos.length]), - maxLines: 1, - style: TextStyle(color: JjColors.textDim, fontSize: 12)), - ), - ), - ), - // 정렬: 같은 것을 다시 누르면 반대 순서 - _sortButton('name', Icons.sort_by_alpha, tr('파일 이름')), - _sortButton('date', Icons.calendar_month, tr('날짜')), - if (c.checked.isNotEmpty) - IconButton( - tooltip: tr('선택한 동영상을 목록에서 제거'), - iconSize: 18, - visualDensity: VisualDensity.compact, - icon: Icon(Icons.playlist_remove, color: JjColors.textDim), - onPressed: c.removeChecked, - ), - IconButton( - tooltip: tr('모두 지우기'), - iconSize: 18, - visualDensity: VisualDensity.compact, - icon: Icon(Icons.clear_all, color: JjColors.textDim), - // 46: 확인 뒤 (목록에서만 빼고 파일은 그대로) - onPressed: c.busy || c.videos.isEmpty - ? null - : () async { - final ok = await confirmAction( - context, - title: tr('목록을 모두 비울까요?'), - body: trf('동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.', [c.videos.length]), - ok: tr('모두 지우기'), - ); - if (ok) c.clearVideos(); - }, - ), - ]), - ), - Flexible( - fit: shrink ? FlexFit.loose : FlexFit.tight, - child: ListView.builder( - shrinkWrap: shrink, - itemCount: c.videos.length, - itemBuilder: (_, i) => _VideoTile(c: c, v: c.videos[i]), - ), - ), - ], - ), - ); - } -} - -class _VideoTile extends StatelessWidget { - final AppController c; - final VideoItem v; - const _VideoTile({required this.c, required this.v}); - - Future _menu(BuildContext context, Offset global) async { - c.select(v); - final busy = v.status == JobStatus.running; - PopupMenuItem item(String id, IconData icon, String label, {bool enabled = true}) => PopupMenuItem( - value: id, - enabled: enabled, - height: 36, - child: Row(children: [ - Icon(icon, size: 18, color: enabled ? JjColors.text : JjColors.textDim), - const SizedBox(width: 10), - Text(label, style: const TextStyle(fontSize: 13)), - ]), - ); - // 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 바꾼다 - final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; - final at = overlay.globalToLocal(global); - final pick = await showMenu( - context: context, - position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy), - items: [ - item('folder', Icons.folder_open, tr('대상 폴더 열기')), - if (c.services.createMediaPlayer != null) item('play', Icons.play_arrow, tr('재생')), - const PopupMenuDivider(), - if (c.aiAvailable) - item('ai', Icons.auto_awesome, tr('AI 자막 만들기'), enabled: !busy && c.ffmpegVersion != null), - if (c.subtitleProvider != null) - item('search', Icons.travel_explore, tr('인터넷 자막 찾기'), enabled: !busy), - item('subtitle', Icons.subtitles_outlined, tr('자막 파일 추가'), enabled: !busy), - const PopupMenuDivider(), - item('remove', Icons.close, tr('목록에서 빼기 (파일은 그대로)'), enabled: !busy), - ], - ); - if (pick == null || !context.mounted) return; - switch (pick) { - case 'folder': - await c.services.shell.revealFile(v.path); - case 'play': - await playFiles(context, c, [v.path]); - case 'ai': - await showAiDialog(context, c, v, targets: [v]); - case 'search': - await showSubtitleSearch(context, c, v); - case 'subtitle': - await c.pickSubtitlesFor(v); - case 'remove': - c.removeVideo(v); - } - } - - // 연달아 누른 횟수 (두 번이면 재생, 세 번 이상이면 이동). 마지막으로 누르고 잠시 뒤에 정한다. - static VideoItem? _tapItem; - static int _taps = 0; - static Timer? _tapTimer; - - void _tapped(BuildContext context) { - c.select(v); - _taps = _tapItem == v ? _taps + 1 : 1; - _tapItem = v; - _tapTimer?.cancel(); - _tapTimer = Timer(const Duration(milliseconds: 350), () { - final n = _taps; - _taps = 0; - _tapItem = null; - if (!context.mounted) return; - if (n == 2) { - playFiles(context, c, [v.path]); - } else if (n >= 3 && c.settings.mkv.tripleTapAction == 'move') { - // 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다 - moveToTarget(context, c, [v], confirm: true); - } - }); - } - - @override - Widget build(BuildContext context) { - final isSel = c.selected == v; - final subs = v.subtitles.where((s) => s.enabled).length; - // 51: 터치 화면에서는 길게 누르면 오른쪽 클릭과 같은 메뉴 (누른 자리에) - return GestureDetector( - onLongPressStart: (d) { - HapticFeedback.selectionClick(); - _menu(context, d.globalPosition); - }, - child: InkWell( - // 한 번: 보기 · 두 번: 재생 · 세 번 이상: 이동 폴더로 옮기기 - onTap: () => _tapped(context), - // 오른쪽 클릭: 이 동영상으로 할 수 있는 일 - onSecondaryTapDown: (d) => _menu(context, d.globalPosition), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - padding: const EdgeInsets.fromLTRB(2, 10, 4, 10), - decoration: BoxDecoration( - color: isSel ? JjColors.panelHigh : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSel ? JjColors.accent.withValues(alpha: 0.6) : Colors.transparent), - ), - child: Row( - children: [ - // 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기) - Checkbox( - visualDensity: VisualDensity.compact, - value: c.checked.contains(v), - onChanged: (_) => c.toggleChecked(v), - ), - _StatusIcon(v.status), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(v.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 4), - if (v.status == JobStatus.running) ...[ - LinearProgressIndicator(value: v.progress, minHeight: 3), - if (v.phase != null) - Padding( - padding: const EdgeInsets.only(top: 3), - child: Text(v.phase!, - style: TextStyle(fontSize: 11, color: JjColors.accent)), - ), - ] else if (v.phase != null) - Text(v.phase!, style: TextStyle(fontSize: 11, color: JjColors.textDim)) - else - Text( - v.status == JobStatus.failed - ? tr('실패') - : v.status == JobStatus.cancelled - ? tr('취소됨') - : trf('자막 {0}개', [subs]), - style: TextStyle( - fontSize: 11, - color: v.status == JobStatus.failed - ? JjColors.danger - : JjColors.textDim), - ), - ], - ), - ), - if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled) - // 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게) - IconButton( - tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'), - iconSize: 16, - onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v), - icon: Icon(Icons.refresh, color: JjColors.textDim), - ), - // 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로) - if (v.status == JobStatus.running && c.buildingMkv) - IconButton( - tooltip: tr('이 동영상만 취소'), - iconSize: 16, - onPressed: () => c.cancelVideos([v]), - icon: Icon(Icons.stop, color: JjColors.danger), - ), - IconButton( - tooltip: tr('목록에서 제거'), - iconSize: 16, - onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v), - icon: Icon(Icons.close, color: JjColors.textDim), - ), - ], - ), - ), - )); - } -} - -class _StatusIcon extends StatelessWidget { - final JobStatus s; - const _StatusIcon(this.s); - - @override - Widget build(BuildContext context) => switch (s) { - JobStatus.ready => - Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim), - JobStatus.running => - Icon(Icons.autorenew, size: 20, color: JjColors.accent), - JobStatus.done => - Icon(Icons.check_circle, size: 20, color: JjColors.success), - JobStatus.failed => - Icon(Icons.error, size: 20, color: JjColors.danger), - // 187: 취소는 실패가 아니라 회색 - JobStatus.cancelled => - Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim), - }; -} - -// ───────── 오른쪽: 선택한 동영상 ───────── - /// 좁은 화면 (폰 세로): 목록 위 · 자세히 아래. /// 15 지적 · 관리자 판단 (10/17): 목록을 높이 32% 로 고정하면 줄이 적을 때 빈 칸이 남고, 많을 때 4~5줄만 보였다. /// '자동' (처음 값) 은 목록이 내용만큼 차지하되 자세히 칸의 최소 높이를 남기고, 남는 공간은 목록에. @@ -1014,11 +133,11 @@ class _CompactBody extends StatelessWidget { final shrink = c.settings.mkv.compactList == 'auto' && c.videos.length * _rowMin < cap; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (shrink) - ConstrainedBox(constraints: BoxConstraints(maxHeight: cap), child: _VideoList(c: c, shrink: true)) + ConstrainedBox(constraints: BoxConstraints(maxHeight: cap), child: HomeVideoList(c: c, shrink: true)) else - SizedBox(height: cap, child: _VideoList(c: c)), + SizedBox(height: cap, child: HomeVideoList(c: c)), const Divider(height: 1), - Expanded(child: picked ? _VideoDetail(c: c, v: c.selected!) : const _PickHint()), + Expanded(child: picked ? VideoDetailPane(c: c, v: c.selected!) : const _PickHint()), ]); }); } @@ -1069,498 +188,3 @@ class _EmptyHint extends StatelessWidget { ), ); } - -class _VideoDetail extends StatelessWidget { - final AppController c; - final VideoItem v; - const _VideoDetail({required this.c, required this.v}); - - @override - Widget build(BuildContext context) { - final info = v.info; - final video = info?.ofType('video').firstOrNull; - final audioCount = info?.ofType('audio').length ?? 0; - // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) - final locked = v.status == JobStatus.running; - - // 오른쪽 아래 이동 버튼들 (환경 설정 > MKV 만들기 > 이동 버튼): 표시 이름으로, 위로 하나씩. - // 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상을 그 버튼의 폴더로 옮긴다. - final moving = c.checked.isEmpty ? [v] : c.batchTargets; - final canMove = moving.any((x) => x.status != JobStatus.running); - final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; - final buttons = c.settings.mkv.moveTargets; - Widget button(MoveTarget? t) => Tooltip( - message: t == null - ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') - : c.checked.isEmpty - ? trf('{0}: 이 동영상을 {1} 로 옮깁니다', [t.name, t.dir]) - : trf('{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다', [t.name, c.checked.length, t.dir]), - child: FloatingActionButton.extended( - heroTag: null, - onPressed: canMove ? () => moveToTarget(context, c, moving, target: t) : null, - icon: const Icon(Icons.drive_file_move_outline), - label: Text('${t?.name ?? tr('이동')}$count'), - ), - ); - // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 - // F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다. - // 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다) - if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) { - final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ - if (buttons.isEmpty) button(null), - for (final t in buttons) button(t), - ]); - return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24); - } - // 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼) - final stacked = buttons.isEmpty ? 1 : buttons.length; - return Stack(children: [ - _list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16), - Positioned( - right: 16, - bottom: 16, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (buttons.isEmpty) button(null), - // 첫 번째 버튼이 맨 아래 (예전 [이동] 자리), 다음 버튼은 그 위로 - for (var i = buttons.length - 1; i >= 0; i--) ...[ - button(buttons[i]), - if (i > 0) const SizedBox(height: 10), - ], - ], - ), - ), - ]); - } - - Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked, - {Widget? moveButtons, double bottom = 88}) { - final compact = isCompact(context); - final side = compact ? 12.0 : 20.0; - // 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게 - final detected = v.aiDetected == null - ? null - : Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ - Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - TextButton( - onPressed: c.busy - ? null - : () async { - final lang = await pickSourceLanguage(context, languageOf(v.aiDetected)); - if (lang != null) await c.generateAiSubtitles([v], c.aiOptions.copyWith(source: lang)); - }, - child: Text(tr('다른 언어로 다시')), - ), - ]); - // 자막 머리 줄: 좁으면 버튼이 다음 줄로 (오른쪽 정렬) - final subtitleActions = Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - ), - const SizedBox(width: 16), - Expanded( - child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ - if (c.aiAvailable) - FilledButton.icon( - onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), - icon: const Icon(Icons.auto_awesome, size: 18), - label: Text(tr('AI 자막 만들기')), - ), - if (c.subtitleProvider != null) - OutlinedButton.icon( - onPressed: locked ? null : () => showSubtitleSearch(context, c, v), - icon: const Icon(Icons.travel_explore, size: 18), - label: Text(tr('인터넷 자막 찾기')), - ), - OutlinedButton.icon( - onPressed: locked ? null : () => c.pickSubtitlesFor(v), - icon: const Icon(Icons.subtitles_outlined, size: 18), - label: Text(tr('자막 파일 추가')), - ), - ]), - ), - ]); - return ListView( - padding: EdgeInsets.fromLTRB(side, side, side, bottom), - children: [ - Row(children: [ - Expanded( - child: Text(v.fileName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - ), - if (c.services.createMediaPlayer != null) ...[ - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () => playFiles(context, c, [v.path]), - icon: const Icon(Icons.play_arrow, size: 18), - label: Text(tr('재생')), - ), - ], - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => c.services.shell.revealFile(v.path), - icon: const Icon(Icons.folder_open, size: 18), - label: Text(tr('폴더 열기')), - ), - ]), - const SizedBox(height: 4), - SelectableText(v.path, - style: TextStyle(color: JjColors.textDim, fontSize: 12)), - const SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: [ - if (info?.duration != null) _Chip(trf('길이 {0}', [_fmt(info!.duration!)])), - if (video != null) ...[ - if (video.width != null) _Chip('${video.width}×${video.height}'), - _Chip(trf('영상 {0}', [video.codec])), - ], - _Chip(trf('음성 {0}개', [audioCount])), - ]), - // 261: 좁은 화면 (폰 세로) 은 세부 정보 칸이 낮다 (노트 10+ 에서 약 210) - "감지한 언어" 줄 · 이동 버튼이 - // [AI 자막 만들기] 를 칸 밖으로 밀었다. 자막 버튼을 칩 바로 아래에 두고, 감지한 언어 · 이동 버튼은 그 다음 - if (compact) ...[ - const SizedBox(height: 12), - subtitleActions, - if (detected != null) ...[const SizedBox(height: 4), detected], - if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], - ] else ...[ - if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], - if (detected != null) ...[const SizedBox(height: 8), detected], - ], - if (v.message != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: JjColors.danger.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: SelectableText(v.message!, - style: TextStyle(color: JjColors.danger, fontSize: 12)), - ), - ], - if (!compact) ...[ - const SizedBox(height: 24), - subtitleActions, - ], - const SizedBox(height: 8), - if (v.subtitles.isEmpty) - Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Text(tr('자막이 없습니다.'), style: TextStyle(color: JjColors.textDim)), - ), - for (final s in v.subtitles) - _SubtitleRow(c: c, v: v, s: s, locked: locked), - const SizedBox(height: 24), - Text(trf('출력: {0}', [outputMkvPath(v.path)]), - style: TextStyle(color: JjColors.textDim, fontSize: 12)), - ], - ); - } - - static String _fmt(Duration d) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; - } -} - -class _Chip extends StatelessWidget { - final String text; - const _Chip(this.text); - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: JjColors.panelHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Text(text, style: const TextStyle(fontSize: 12)), - ); -} - -class _SubtitleRow extends StatelessWidget { - final AppController c; - final VideoItem v; - final SubtitleEntry s; - final bool locked; - const _SubtitleRow( - {required this.c, required this.v, required this.s, required this.locked}); - - /// 관리자 지시 (10/21, 15 찾음 F6): 한 줄에 들어가는 폭 - 내장 표 · 이름 (최소) · 언어 고르기 · 문자셋 · 단추. - /// 고르기는 가장 긴 항목 글의 폭을 쓰므로 (DropdownButton) 그 글을 지금 글꼴 · 글자 배율로 잰다 - static double _oneLineWidth(BuildContext context, {required bool embedded, required int buttons}) { - final scaler = MediaQuery.textScalerOf(context); - final base = DefaultTextStyle.of(context).style; - double textW(String t, double size) => - (TextPainter(text: TextSpan(text: t, style: base.copyWith(fontSize: size)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1) - ..layout()) - .width; - double widest(Iterable xs, double size) => xs.fold(0.0, (m, t) => math.max(m, textW(t, size))); - final lang = widest([tr('언어: 미지정'), for (final l in languages) '${l.name} (${l.code})'], 12) + 24; - final charset = embedded ? 0.0 : widest(supportedCharsets.values, 12) + 24 + 8; - final badge = textW(embedded ? tr('내장') : tr('외부'), 11) + 12; - const nameMin = 60.0; - return badge + 10 + nameMin + 8 + lang + charset + 4 + buttons * 48; - } - - @override - Widget build(BuildContext context) { - final embedded = s.kind == SubtitleKind.embedded; - final off = !s.enabled; - final badge = Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: (embedded ? JjColors.textDim : JjColors.accent) - .withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(4), - ), - child: Text(embedded ? tr('내장') : tr('외부'), - style: TextStyle( - fontSize: 11, - color: embedded ? JjColors.textDim : JjColors.accent)), - ); - final name = Text( - s.displayName, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - color: off ? JjColors.textDim : JjColors.text, - decoration: off ? TextDecoration.lineThrough : null, - ), - ); - final pickers = [ - _LanguageDropdown( - value: s.language, - onChanged: locked || off ? null : (l) => c.setLanguage(s, l), - ), - if (!embedded) - _CharsetDropdown( - value: s.charset ?? 'UTF-8', - onChanged: locked ? null : (cs) => c.setCharset(s, cs), - ), - ]; - final buttons = [ - IconButton( - tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'), - iconSize: 18, - onPressed: locked || off || !c.canEdit(s) - ? null - : () => openSubtitleEditor(context, c, v, s), - icon: Icon(Icons.edit_note, color: JjColors.accent), - ), - if (c.services.createTranslator != null) - IconButton( - tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'), - iconSize: 18, - onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), - icon: Icon(Icons.translate, color: JjColors.accent), - ), - IconButton( - tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'), - iconSize: 18, - onPressed: locked ? null : () => c.removeSubtitle(v, s), - icon: Icon( - embedded && off ? Icons.undo : Icons.delete_outline, - color: JjColors.textDim, - ), - ), - ]; - return Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: JjColors.panel, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: JjColors.border), - ), - child: LayoutBuilder(builder: (context, box) { - final fits = box.maxWidth >= _oneLineWidth(context, embedded: embedded, buttons: buttons.length); - if (fits) { - // 넓을 때: 지금처럼 한 줄 - return Row(children: [ - badge, - const SizedBox(width: 10), - Expanded(child: name), - const SizedBox(width: 8), - for (final (i, x) in pickers.indexed) ...[if (i > 0) const SizedBox(width: 8), x], - const SizedBox(width: 4), - ...buttons, - ]); - } - // 좁아 넘칠 때만 두 줄: 위 = 내장 표 · 트랙 이름, 아래 = 언어 고르기 · 단추 (언어 이름은 줄이지 않는다 - 무엇을 골랐는지 보이게) - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [badge, const SizedBox(width: 10), Expanded(child: name)]), - const SizedBox(height: 2), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - children: [...pickers, Row(mainAxisSize: MainAxisSize.min, children: buttons)], - ), - ]); - }), - ); - } -} - -class _LanguageDropdown extends StatelessWidget { - final Language value; - final ValueChanged? onChanged; - const _LanguageDropdown({required this.value, required this.onChanged}); - - @override - Widget build(BuildContext context) { - final items = [undetermined, ...languages]; - return DropdownButton( - value: items.contains(value) ? value : undetermined, - isDense: true, - underline: const SizedBox(), - style: TextStyle(fontSize: 12, color: JjColors.text), - items: [ - for (final l in items) - DropdownMenuItem( - value: l, - child: Text(l == undetermined ? tr('언어: 미지정') : '${l.name} (${l.code})'), - ), - ], - onChanged: onChanged == null ? null : (l) => onChanged!(l!), - ); - } -} - -class _CharsetDropdown extends StatelessWidget { - final String value; - final ValueChanged? onChanged; - const _CharsetDropdown({required this.value, required this.onChanged}); - - @override - Widget build(BuildContext context) => DropdownButton( - value: supportedCharsets.containsKey(value) ? value : 'UTF-8', - isDense: true, - underline: const SizedBox(), - style: TextStyle(fontSize: 12, color: JjColors.text), - items: [ - for (final e in supportedCharsets.entries) - DropdownMenuItem(value: e.key, child: Text(e.value)), - ], - onChanged: onChanged == null ? null : (v) => onChanged!(v!), - ); -} - -// ───────── 아래: 작업 기록 ───────── - -class _LogPanel extends StatelessWidget { - final List logs; - final VoidCallback onClose; - const _LogPanel({required this.logs, required this.onClose}); - - /// 폰 세로에서 기록이 비었을 때의 높이 (안내 한 줄) - static const emptyHeight = 40.0; - - @override - Widget build(BuildContext context) => Container( - color: JjColors.bg, - child: Stack(children: [ - // 15 지적: 기록이 없으면 ✕ 하나만 있는 빈 칸으로 보였다 - 무엇이 들어오는 칸인지 적는다 - if (logs.isEmpty) - Positioned.fill( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 44, 0), - child: Text(tr('작업 기록이 아직 없습니다. MKV 만들기 · 자막 · 다운로드를 하면 여기에 차례로 남습니다.'), - maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ), - Positioned.fill( - child: ListView.builder( - reverse: true, - padding: const EdgeInsets.fromLTRB(16, 8, 44, 8), - itemCount: logs.length, - itemBuilder: (_, i) => SelectableText( - logs[logs.length - 1 - i], - style: TextStyle( - fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), - ), - ), - ), - // 오른쪽 위 ✕: 작업 기록 숨기기 - Positioned( - top: 2, - right: 4, - child: IconButton( - tooltip: tr('작업 기록 숨기기'), - iconSize: 16, - visualDensity: VisualDensity.compact, - icon: Icon(Icons.close, color: JjColors.textDim), - onPressed: onClose, - ), - ), - ]), - ); -} - -/// 작업 기록을 숨겼을 때 맨 아래 얇은 줄: 마지막 기록 + 다시 보기 -class _LogStrip extends StatelessWidget { - final String last; - final VoidCallback onOpen; - const _LogStrip({required this.last, required this.onOpen}); - - @override - Widget build(BuildContext context) => Material( - color: JjColors.bg, - child: InkWell( - onTap: onOpen, - child: SizedBox( - height: 26, - child: Row(children: [ - const SizedBox(width: 16), - Expanded( - child: Text(last, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)), - ), - Text(tr('작업 기록 보기'), style: TextStyle(fontSize: 11, color: JjColors.accent)), - Icon(Icons.expand_less, size: 16, color: JjColors.accent), - const SizedBox(width: 12), - ]), - ), - ), - ); -} - -/// 동영상을 이동 버튼의 폴더로 옮긴다 (세부 정보의 이동 버튼 · 세 번 누르기). [target] 이 없으면 첫 번째 버튼, -/// 이동 버튼이 하나도 없으면 폴더를 골라 하나 만든다 (표시 이름은 폴더 이름). -/// [confirm]: 옮기기 전에 묻는다 (세 번 누르기 - 47) -Future moveToTarget(BuildContext context, AppController c, List videos, - {MoveTarget? target, bool confirm = false}) async { - if (videos.isEmpty) return; - final messenger = ScaffoldMessenger.maybeOf(context); - var t = target ?? c.settings.mkv.moveTargets.firstOrNull; - if (t == null) { - final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)')); - if (d == null) return; - t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d); - final add = t; - await c.updateSettings((x) => x.mkv.moveTargets = [...x.mkv.moveTargets, add]); - } else if (confirm && context.mounted) { - final ok = await confirmAction( - context, - title: tr('파일 옮기기'), - body: '${trf('"{0}" 을(를) "{1}" 폴더로 옮길까요?', [videos.length == 1 ? videos.single.fileName : trf('{0}개', [videos.length]), t.name])}' - '\n${t.dir}\n\n${tr('(동영상을 세 번 누르면 옮깁니다)')}', - ok: tr('옮기기'), - danger: false, - ); - if (!ok) return; - } - final (moved, errors) = await c.moveVideos(videos, t.dir); - messenger?.showSnackBar(SnackBar( - content: Text([ - if (moved > 0) trf('{0}개를 옮겼습니다 → {1} ({2})', [moved, t.name, t.dir]), - ...errors, - ].join('\n')), - )); -} diff --git a/test/home_modules_contract_test.dart b/test/home_modules_contract_test.dart new file mode 100644 index 0000000..0aef55b --- /dev/null +++ b/test/home_modules_contract_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/ui/home/log_panel.dart'; +import 'package:jj_mkvmaker/ui/home/video_list_view.dart'; + +/// home_page 를 나눈 모듈의 약속 (실제 위젯으로) +void main() { + Widget host(Widget w) => MaterialApp(home: Scaffold(body: SizedBox(width: 800, height: 300, child: w))); + + testWidgets('약속 LogPanel · LogStrip: 새 기록이 맨 아래 · 비면 안내 · ✕ 는 닫기 · 한 줄을 누르면 열기', (t) async { + var closed = 0, opened = 0; + await t.pumpWidget(host(LogPanel(logs: const [], onClose: () => closed++))); + expect(find.textContaining('작업 기록이 아직 없습니다'), findsOneWidget); + await t.pumpWidget(host(LogPanel(logs: const ['첫 줄', '둘째 줄'], onClose: () => closed++))); + expect(find.textContaining('작업 기록이 아직 없습니다'), findsNothing); + expect(t.getTopLeft(find.text('둘째 줄')).dy, greaterThan(t.getTopLeft(find.text('첫 줄')).dy), + reason: '거꾸로 쌓아 새 기록이 아래 칸의 맨 아래 (reverse 목록)'); + await t.tap(find.byTooltip('작업 기록 숨기기')); + expect(closed, 1); + await t.pumpWidget(host(LogStrip(last: '마지막 기록', onOpen: () => opened++))); + expect(find.text('마지막 기록'), findsOneWidget); + await t.tap(find.text('작업 기록 보기')); + expect(opened, 1); + }); + + testWidgets('약속 JobStatusIcon: 상태마다 다른 아이콘 (취소는 실패와 다름)', (t) async { + final icons = {}; + for (final s in JobStatus.values) { + await t.pumpWidget(host(JobStatusIcon(s))); + icons.add(t.widget(find.byType(Icon)).icon); + } + expect(icons.length, JobStatus.values.length); + }); +}