872 lines
32 KiB
Dart
872 lines
32 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:desktop_drop/desktop_drop.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
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 '../core/playlist.dart';
|
||
import 'ai_dialog.dart';
|
||
import 'app_actions.dart';
|
||
import 'browser_page.dart';
|
||
import 'downloads_page.dart';
|
||
import 'player_page.dart';
|
||
import 'subtitle_editor_page.dart';
|
||
import 'subtitle_search_dialog.dart';
|
||
import 'theme.dart';
|
||
import 'translate_dialog.dart';
|
||
import 'work_panel.dart';
|
||
|
||
class HomePage extends StatelessWidget {
|
||
final AppController c;
|
||
|
||
/// 다운로드 (없으면 "다운로딩" 상자 숨김)
|
||
final DownloadManager? downloads;
|
||
|
||
/// 종료 버튼 (없으면 숨김)
|
||
final VoidCallback? onExit;
|
||
|
||
/// 즐겨찾기 (없으면 브라우저 버튼 숨김)
|
||
final BookmarksController? bookmarks;
|
||
|
||
const HomePage({super.key, required this.c, this.downloads, this.onExit, this.bookmarks});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: c,
|
||
builder: (context, _) => Scaffold(
|
||
// 탐색기에서 동영상 · 폴더를 끌어다 놓으면 동영상만 추가
|
||
body: DropTarget(
|
||
onDragDone: (d) async {
|
||
final videos = await collectVideos(d.files.map((f) => f.path),
|
||
isDirectory: (x) => FileSystemEntity.isDirectory(x),
|
||
listDir: (x) async => Directory(x).list().map((e) => e.path).toList());
|
||
if (videos.isEmpty) {
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('끌어다 놓은 항목에 동영상이 없습니다.')));
|
||
}
|
||
return;
|
||
}
|
||
await c.addVideos(videos);
|
||
},
|
||
child: Column(
|
||
children: [
|
||
_TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
|
||
const Divider(height: 1),
|
||
_EncodeBar(c: c),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
SizedBox(width: 320, child: _VideoList(c: c)),
|
||
const VerticalDivider(width: 1),
|
||
Expanded(
|
||
child: c.selected == null
|
||
? const _EmptyHint()
|
||
: _VideoDetail(c: c, v: c.selected!),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
if (c.settings.showLog)
|
||
SizedBox(
|
||
height: 140,
|
||
child: _LogPanel(
|
||
logs: c.logs,
|
||
onClose: () => c.updateSettings((s) => s.showLog = false),
|
||
),
|
||
)
|
||
else
|
||
_LogStrip(
|
||
last: c.logs.isEmpty ? '' : c.logs.last,
|
||
onOpen: () => c.updateSettings((s) => s.showLog = true),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ───────── 상단 바 ─────────
|
||
|
||
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});
|
||
|
||
static String _batchHint(AppController c, String what) => c.checked.isEmpty
|
||
? '지금 보고 있는 동영상의 $what (여러 개는 목록에서 체크)'
|
||
: '체크한 동영상 ${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})';
|
||
return Container(
|
||
height: appBarHeight,
|
||
color: JjColors.panel,
|
||
padding: const EdgeInsets.only(left: 16, right: appBarRightPadding),
|
||
child: Row(
|
||
children: [
|
||
// 프로그램 아이콘 (tool/make_icon.py 로 생성)
|
||
Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium),
|
||
const SizedBox(width: 8),
|
||
const Text('JJ_MKVMaker',
|
||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 24),
|
||
// 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록)
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(children: [
|
||
OutlinedButton.icon(
|
||
onPressed: c.pickVideos,
|
||
icon: const Icon(Icons.add, size: 18),
|
||
label: const Text('동영상 추가'),
|
||
),
|
||
if (c.aiAvailable) ...[
|
||
const SizedBox(width: 8),
|
||
Tooltip(
|
||
message: _batchHint(c, 'AI 자막을 만듭니다'),
|
||
child: OutlinedButton.icon(
|
||
onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
|
||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||
label: Text('자막 만들기$count'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Tooltip(
|
||
message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'),
|
||
child: OutlinedButton.icon(
|
||
onPressed: canBatch
|
||
? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true)
|
||
: null,
|
||
icon: const Icon(Icons.auto_mode, size: 18),
|
||
label: Text('자막 만들기 & MKV 만들기$count'),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (c.busy) ...[
|
||
JobIndicator(c: c),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: c.cancel,
|
||
icon: const Icon(Icons.stop, size: 18, color: JjColors.danger),
|
||
label: const Text('취소'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
// 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개)
|
||
if (c.services.createMediaPlayer != null) ...[
|
||
Tooltip(
|
||
message: c.checked.isEmpty
|
||
? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)'
|
||
: '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다',
|
||
child: OutlinedButton.icon(
|
||
onPressed: batch.isEmpty
|
||
? null
|
||
: () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true),
|
||
icon: const Icon(Icons.playlist_play, size: 20),
|
||
label: Text('선택한 파일 재생$count'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
FilledButton.icon(
|
||
// 체크한 동영상이 있으면 그것만, 없으면 목록 전체
|
||
onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)),
|
||
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
|
||
label: Text('MKV 만들기$count${c.busy ? ' (대기열)' : ''}'),
|
||
),
|
||
if (bookmarks != null) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))),
|
||
icon: const Icon(Icons.public, size: 18),
|
||
label: const Text('브라우저'),
|
||
),
|
||
],
|
||
if (downloads != null) ...[
|
||
const SizedBox(width: 12),
|
||
_DownloadBox(d: downloads!),
|
||
],
|
||
// 환경 설정 · 종료: 모든 화면에서 같은 자리
|
||
AppActions(c: c, onExit: onExit),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 상단 "다운로딩" 상자: 누르면 다운로드 목록
|
||
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: () => Navigator.push(
|
||
context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: 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
|
||
? '다운로딩 $n${p == null ? '' : ' · ${(p * 100).toStringAsFixed(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;
|
||
final locked = c.busy;
|
||
final up = c.upscaleCount;
|
||
|
||
Widget label(String t) => Padding(
|
||
padding: const EdgeInsets.only(right: 6),
|
||
child: Text(t, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
);
|
||
Widget drop<T>(T value, List<T> items, String Function(T) text,
|
||
ValueChanged<T> onChanged, {bool Function(T)? enabled}) =>
|
||
DropdownButton<T>(
|
||
value: value,
|
||
isDense: true,
|
||
underline: const SizedBox(),
|
||
style: const 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) ? '' : ' (사용 불가)'),
|
||
style: TextStyle(
|
||
color: (enabled?.call(i) ?? true) ? null : JjColors.textDim),
|
||
),
|
||
),
|
||
],
|
||
onChanged: locked ? null : (v) => onChanged(v as T),
|
||
);
|
||
|
||
return Container(
|
||
height: 44,
|
||
color: JjColors.panel,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(children: [
|
||
label('화면 크기'),
|
||
drop(s.resolution, ResolutionChoice.values, (r) => r.label, c.setResolution),
|
||
const SizedBox(width: 20),
|
||
label('코덱'),
|
||
drop(s.codec, VideoCodecChoice.values, (v) => v.label, c.setCodec,
|
||
enabled: c.isCodecAvailable),
|
||
const SizedBox(width: 20),
|
||
if (s.reencode) ...[
|
||
label('화질'),
|
||
drop(s.quality, QualityChoice.values, (q) => q.label, c.setQuality),
|
||
const SizedBox(width: 20),
|
||
],
|
||
Expanded(
|
||
child: Text(
|
||
!s.reencode
|
||
? '영상·음성을 그대로 복사합니다 (빠름, 화질 손실 없음)'
|
||
: '영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로)'
|
||
'${up > 0 ? ' · ⚠ $up개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다' : ''}',
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ───────── 왼쪽: 동영상 목록 ─────────
|
||
|
||
class _VideoList extends StatelessWidget {
|
||
final AppController c;
|
||
const _VideoList({required this.c});
|
||
|
||
/// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표
|
||
Widget _sortButton(String by, IconData icon, String name) {
|
||
final on = c.sortedBy == by;
|
||
return IconButton(
|
||
tooltip: '$name 순으로 정렬'
|
||
'${on ? (c.sortAscending ? ' (지금: 오름차순 · 다시 누르면 내림차순)' : ' (지금: 내림차순 · 다시 누르면 오름차순)') : ''}',
|
||
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,
|
||
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: Text(
|
||
c.checked.isEmpty
|
||
? '동영상 ${c.videos.length}개 · 전체 선택'
|
||
: '선택 ${c.checked.length} / ${c.videos.length}개',
|
||
style: const TextStyle(color: JjColors.textDim, fontSize: 12)),
|
||
),
|
||
),
|
||
// 정렬: 같은 것을 다시 누르면 반대 순서
|
||
_sortButton('name', Icons.sort_by_alpha, '파일 이름'),
|
||
_sortButton('date', Icons.calendar_month, '날짜'),
|
||
if (c.checked.isNotEmpty)
|
||
IconButton(
|
||
tooltip: '선택한 동영상을 목록에서 제거',
|
||
iconSize: 18,
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.playlist_remove, color: JjColors.textDim),
|
||
onPressed: c.removeChecked,
|
||
),
|
||
IconButton(
|
||
tooltip: '모두 지우기',
|
||
iconSize: 18,
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.clear_all, color: JjColors.textDim),
|
||
onPressed: c.busy || c.videos.isEmpty ? null : c.clearVideos,
|
||
),
|
||
]),
|
||
),
|
||
Expanded(
|
||
child: ListView.builder(
|
||
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});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isSel = c.selected == v;
|
||
final subs = v.subtitles.where((s) => s.enabled).length;
|
||
return InkWell(
|
||
onTap: () => c.select(v),
|
||
onDoubleTap: () => playFiles(context, c, [v.path]),
|
||
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: const TextStyle(fontSize: 11, color: JjColors.accent)),
|
||
),
|
||
] else if (v.phase != null)
|
||
Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim))
|
||
else
|
||
Text(
|
||
v.status == JobStatus.failed ? '실패' : '자막 $subs개',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: v.status == JobStatus.failed
|
||
? JjColors.danger
|
||
: JjColors.textDim),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (v.status == JobStatus.done || v.status == JobStatus.failed)
|
||
IconButton(
|
||
tooltip: '다시 만들기 대기로',
|
||
iconSize: 16,
|
||
onPressed: () => c.resetStatus(v),
|
||
icon: const Icon(Icons.refresh, color: JjColors.textDim),
|
||
),
|
||
IconButton(
|
||
tooltip: '목록에서 제거',
|
||
iconSize: 16,
|
||
onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v),
|
||
icon: const 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 =>
|
||
const Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim),
|
||
JobStatus.running =>
|
||
const Icon(Icons.autorenew, size: 20, color: JjColors.accent),
|
||
JobStatus.done =>
|
||
const Icon(Icons.check_circle, size: 20, color: JjColors.success),
|
||
JobStatus.failed =>
|
||
const Icon(Icons.error, size: 20, color: JjColors.danger),
|
||
};
|
||
}
|
||
|
||
// ───────── 오른쪽: 선택한 동영상 ─────────
|
||
|
||
class _EmptyHint extends StatelessWidget {
|
||
const _EmptyHint();
|
||
|
||
@override
|
||
Widget build(BuildContext context) => const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.video_library_outlined, size: 56, color: JjColors.textDim),
|
||
SizedBox(height: 12),
|
||
Text('"동영상 추가" 로 파일을 선택하세요',
|
||
style: TextStyle(color: JjColors.textDim)),
|
||
SizedBox(height: 4),
|
||
Text('같은 폴더의 자막은 자동으로 추가됩니다',
|
||
style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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;
|
||
|
||
return ListView(
|
||
padding: const EdgeInsets.all(20),
|
||
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: const Text('재생'),
|
||
),
|
||
],
|
||
]),
|
||
const SizedBox(height: 4),
|
||
SelectableText(v.path,
|
||
style: const TextStyle(color: JjColors.textDim, fontSize: 12)),
|
||
const SizedBox(height: 12),
|
||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||
if (info?.duration != null) _Chip('길이 ${_fmt(info!.duration!)}'),
|
||
if (video != null) ...[
|
||
if (video.width != null) _Chip('${video.width}×${video.height}'),
|
||
_Chip('영상 ${video.codec}'),
|
||
],
|
||
_Chip('음성 $audioCount개'),
|
||
]),
|
||
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: const TextStyle(color: JjColors.danger, fontSize: 12)),
|
||
),
|
||
],
|
||
const SizedBox(height: 24),
|
||
Row(children: [
|
||
const Text('자막',
|
||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
const Spacer(),
|
||
if (c.aiAvailable) ...[
|
||
FilledButton.icon(
|
||
onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v),
|
||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||
label: const Text('AI 자막 만들기'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
if (c.subtitleProvider != null) ...[
|
||
OutlinedButton.icon(
|
||
onPressed: locked ? null : () => showSubtitleSearch(context, c, v),
|
||
icon: const Icon(Icons.travel_explore, size: 18),
|
||
label: const Text('인터넷 자막 찾기'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
OutlinedButton.icon(
|
||
onPressed: locked ? null : () => c.pickSubtitlesFor(v),
|
||
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||
label: const Text('자막 파일 추가'),
|
||
),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
if (v.subtitles.isEmpty)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 16),
|
||
child: Text('자막이 없습니다.', 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('출력: ${outputMkvPath(v.path)}',
|
||
style: const 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});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final embedded = s.kind == SubtitleKind.embedded;
|
||
final off = !s.enabled;
|
||
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: Row(
|
||
children: [
|
||
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 ? '내장' : '외부',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: embedded ? JjColors.textDim : JjColors.accent)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
s.displayName,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: off ? JjColors.textDim : JjColors.text,
|
||
decoration: off ? TextDecoration.lineThrough : null,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_LanguageDropdown(
|
||
value: s.language,
|
||
onChanged: locked || off ? null : (l) => c.setLanguage(s, l),
|
||
),
|
||
if (!embedded) ...[
|
||
const SizedBox(width: 8),
|
||
_CharsetDropdown(
|
||
value: s.charset ?? 'UTF-8',
|
||
onChanged: locked ? null : (cs) => c.setCharset(s, cs),
|
||
),
|
||
],
|
||
const SizedBox(width: 4),
|
||
IconButton(
|
||
tooltip: c.canEdit(s) ? '내용 편집 · 문자셋 변환' : '이미지 자막은 편집할 수 없습니다',
|
||
iconSize: 18,
|
||
onPressed: locked || off || !c.canEdit(s)
|
||
? null
|
||
: () => openSubtitleEditor(context, c, v, s),
|
||
icon: const Icon(Icons.edit_note, color: JjColors.accent),
|
||
),
|
||
if (c.services.createTranslator != null)
|
||
IconButton(
|
||
tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 PC 에서)' : '이미지 자막은 번역할 수 없습니다',
|
||
iconSize: 18,
|
||
onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s),
|
||
icon: const Icon(Icons.translate, color: JjColors.accent),
|
||
),
|
||
IconButton(
|
||
tooltip: embedded ? (off ? '되살리기' : '삭제 (출력에서 제외)') : '목록에서 제거',
|
||
iconSize: 18,
|
||
onPressed: locked ? null : () => c.removeSubtitle(v, s),
|
||
icon: Icon(
|
||
embedded && off ? Icons.undo : Icons.delete_outline,
|
||
color: JjColors.textDim,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LanguageDropdown extends StatelessWidget {
|
||
final Language value;
|
||
final ValueChanged<Language>? onChanged;
|
||
const _LanguageDropdown({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: const TextStyle(fontSize: 12, color: JjColors.text),
|
||
items: [
|
||
for (final l in items)
|
||
DropdownMenuItem(
|
||
value: l,
|
||
child: Text(l == undetermined ? '언어: 미지정' : '${l.name} (${l.code})'),
|
||
),
|
||
],
|
||
onChanged: onChanged == null ? null : (l) => onChanged!(l!),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CharsetDropdown extends StatelessWidget {
|
||
final String value;
|
||
final ValueChanged<String>? onChanged;
|
||
const _CharsetDropdown({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: const 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<String> logs;
|
||
final VoidCallback onClose;
|
||
const _LogPanel({required this.logs, required this.onClose});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
color: JjColors.bg,
|
||
child: Stack(children: [
|
||
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: const TextStyle(
|
||
fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim),
|
||
),
|
||
),
|
||
),
|
||
// 오른쪽 위 ✕: 작업 기록 숨기기
|
||
Positioned(
|
||
top: 2,
|
||
right: 4,
|
||
child: IconButton(
|
||
tooltip: '작업 기록 숨기기',
|
||
iconSize: 16,
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const 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: const TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)),
|
||
),
|
||
const Text('작업 기록 보기', style: TextStyle(fontSize: 11, color: JjColors.accent)),
|
||
const Icon(Icons.expand_less, size: 16, color: JjColors.accent),
|
||
const SizedBox(width: 12),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|