home_page 나누기 (1566 → 190): 위쪽 막대 · 인코딩 막대 · MKV 목록 · 자세히 · 자막 줄 · 작업 기록 · 이동을 lib/ui/home/ 모듈로

- 화면 조각은 공개 위젯 (key 받음), 일은 AppController 가 · 화면은 부르기만
- 약속 시험: LogPanel · LogStrip · JobStatusIcon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 02:26:10 +09:00
parent 5de4412dc2
commit 1689221019
9 changed files with 1516 additions and 1391 deletions

198
lib/ui/home/encode_bar.dart Normal file
View File

@ -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>(T value, List<T> items, String Function(T) text,
ValueChanged<T> onChanged, {bool Function(T)? enabled}) =>
ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxDrop),
child: IntrinsicWidth(
child: DropdownButton<T>(
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,
);
}
}
// ───────── 왼쪽: 동영상 목록 ─────────

View File

@ -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<void> _cancelJobs(BuildContext context) async {
final inBatch = c.batchWaiting;
final waiting = c.pendingJobs.length + inBatch;
// 239: 창을 연 때 돌고 있던 것 - [지금 것만 취소] 는 이것만 (그사이 끝나고 다음 것이 시작해도 다음 것은 취소하지 않는다)
final targets = c.buildingMkv ? c.runningVideos : const <VideoItem>[];
if (waiting == 0) {
// 240: 여럿이 동시에 돌고 있으면 묻는다 (하나씩은 목록 줄의 ■)
if (targets.length > 1) {
final all = await showDialog<bool>(
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<String>(
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<String>(
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<void> _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<void> _confirmBuildAll(BuildContext context) async {
final todo = c.videos.where((v) => v.status != JobStatus.done).length;
final ok = await showDialog<bool>(
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<Widget> 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!),
],
],
),
);
});
}
}

View File

@ -0,0 +1,86 @@
/// 약속: 작업 기록을 보여 준다 (넓은 화면은 아래 칸, 좁은 화면은 한 줄 - 누르면 전체).
library;
import 'package:flutter/material.dart';
import '../theme.dart';
import '../../l10n/tr.dart';
class LogPanel extends StatelessWidget {
final List<String> 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),
]),
),
),
);
}

View File

@ -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<void> moveToTarget(BuildContext context, AppController c, List<VideoItem> 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')),
));
}

View File

@ -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<String> 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 = <Widget>[
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 = <Widget>[
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<Language>? onChanged;
const LanguageDropdown({super.key, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final items = [undetermined, ...languages];
return DropdownButton<Language>(
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<String>? onChanged;
const CharsetDropdown({super.key, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) => DropdownButton<String>(
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!),
);
}
// ───────── 아래: 작업 기록 ─────────

View File

@ -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)),
);
}

View File

@ -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<void> _menu(BuildContext context, Offset global) async {
c.select(v);
final busy = v.status == JobStatus.running;
PopupMenuItem<String> 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<String>(
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),
};
}
// ───────── 오른쪽: 선택한 동영상 ─────────

File diff suppressed because it is too large Load Diff

View File

@ -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 = <IconData?>{};
for (final s in JobStatus.values) {
await t.pumpWidget(host(JobStatusIcon(s)));
icons.add(t.widget<Icon>(find.byType(Icon)).icon);
}
expect(icons.length, JobStatus.values.length);
});
}