467 lines
16 KiB
Dart
467 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../app/app_controller.dart';
|
|
import '../app/ai_local.dart';
|
|
import '../app/download_manager.dart';
|
|
import '../core/models.dart';
|
|
import '../services/downloader.dart';
|
|
import 'ai_upscale_ui.dart' show openFolderInApp;
|
|
import 'app_actions.dart';
|
|
import 'downloads_page.dart';
|
|
import 'network_settings.dart' show DnsFailureLine;
|
|
import 'theme.dart';
|
|
import '../l10n/tr.dart';
|
|
|
|
/// "작업 현황" 화면 (전체 화면): Android 작업 알림 · 위쪽 작업 표시를 누르면 연다
|
|
class WorkStatusPage extends StatefulWidget {
|
|
final AppController c;
|
|
final DownloadManager? downloads;
|
|
const WorkStatusPage({super.key, required this.c, this.downloads});
|
|
|
|
/// 열려 있는 작업 현황 화면 수
|
|
static int _open = 0;
|
|
|
|
@override
|
|
State<WorkStatusPage> createState() => _WorkStatusPageState();
|
|
|
|
static const routeName = 'jobs';
|
|
|
|
/// 작업 현황으로: 이미 열려 있으면 그 화면으로, 없으면 새로
|
|
static Future<void> open(NavigatorState nav, AppController c, DownloadManager? downloads) async {
|
|
if (_open > 0) {
|
|
var found = false;
|
|
nav.popUntil((r) {
|
|
if (r.settings.name == routeName) found = true;
|
|
return found || r.isFirst;
|
|
});
|
|
if (found) return;
|
|
}
|
|
await nav.push(MaterialPageRoute<void>(
|
|
settings: const RouteSettings(name: routeName),
|
|
builder: (_) => WorkStatusPage(c: c, downloads: downloads),
|
|
));
|
|
}
|
|
|
|
}
|
|
|
|
class _WorkStatusPageState extends State<WorkStatusPage> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WorkStatusPage._open++;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WorkStatusPage._open--;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
body: Column(children: [
|
|
AppTopBar(
|
|
nav: const AppNavButtons(),
|
|
actions: AppActions(c: widget.c),
|
|
middle: Row(children: [
|
|
Text(tr('작업 현황'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
]),
|
|
),
|
|
Expanded(
|
|
child: WorkPanel(
|
|
c: widget.c,
|
|
downloads: widget.downloads,
|
|
onClose: () => Navigator.maybePop(context),
|
|
onOpenHome: () => AppNavButtons.toMkv(context),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
/// 브라우저 오른쪽 "작업 현황" (화면 분할): 자막 · MKV 작업과 다운로드 진행을 보면서 인터넷을 볼 수 있게.
|
|
class WorkPanel extends StatelessWidget {
|
|
final AppController c;
|
|
final DownloadManager? downloads;
|
|
final VoidCallback onClose;
|
|
|
|
/// MKV (편집) 화면으로 돌아가기
|
|
final VoidCallback onOpenHome;
|
|
|
|
const WorkPanel({
|
|
super.key,
|
|
required this.c,
|
|
required this.downloads,
|
|
required this.onClose,
|
|
required this.onOpenHome,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ListenableBuilder(
|
|
listenable: Listenable.merge([c, ?downloads, AiJobs.instance]),
|
|
builder: (context, _) => Container(
|
|
color: JjColors.panel,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_header(context),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
|
children: [
|
|
..._jobs(context),
|
|
_title(trf('편집 목록 {0}개', [c.videos.length])),
|
|
if (c.videos.isEmpty)
|
|
_Dim(tr('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).')),
|
|
for (final v in c.videos) _VideoRow(v: v),
|
|
if (downloads != null) ...[
|
|
const SizedBox(height: 12),
|
|
_title(
|
|
trf('다운로드 {0}개', [downloads!.tasks.length]),
|
|
action: TextButton(
|
|
onPressed: () => DownloadsPage.open(Navigator.of(context), downloads!),
|
|
child: Text(
|
|
tr('전체 목록'),
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
if (downloads!.tasks.isEmpty)
|
|
_Dim(tr('동영상 페이지에서 [다운로드] 를 누르세요.')),
|
|
for (final t in downloads!.tasks.reversed.take(30))
|
|
_DownloadRow(c: c, t: t),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _header(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(tr('작업 현황'), style: TextStyle(fontWeight: FontWeight.w600)),
|
|
),
|
|
IconButton(
|
|
tooltip: tr('동영상 추가'),
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.add),
|
|
onPressed: c.pickVideos,
|
|
),
|
|
IconButton(
|
|
tooltip: c.busy ? tr('MKV 만들기 (대기열에 추가)') : tr('MKV 만들기'),
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.play_arrow, color: JjColors.accent),
|
|
onPressed: c.videos.isEmpty || c.ffmpegVersion == null
|
|
? null
|
|
: c.buildAll,
|
|
),
|
|
if (c.busy)
|
|
IconButton(
|
|
tooltip: tr('모든 작업 취소'),
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.stop, color: JjColors.danger),
|
|
onPressed: c.cancel,
|
|
),
|
|
IconButton(
|
|
tooltip: tr('MKV 화면으로 (전체 화면)'),
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.open_in_full),
|
|
onPressed: onOpenHome,
|
|
),
|
|
IconButton(
|
|
tooltip: tr('작업 현황 닫기'),
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.close),
|
|
onPressed: onClose,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
List<Widget> _jobs(BuildContext context) => [
|
|
// 249: DNS 서버에 닿지 않음 (안내를 놓쳐도 여기서 보고 바꿀 수 있게)
|
|
DnsFailureLine(c: c),
|
|
_title(tr('작업')),
|
|
// 123: AI 그림 (남은 장 · 예상 시간)
|
|
if (AiJobs.instance.statusLine case final ai?) ...[
|
|
Text('▶ $ai', style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
|
LinearProgressIndicator(value: AiJobs.instance.progress),
|
|
],
|
|
// 157: 끝난 AI 일 - 어디에 저장했는지와 [열기]
|
|
if (!AiJobs.instance.busy && AiJobs.instance.lastDone != null)
|
|
Row(children: [
|
|
Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))),
|
|
// 176: 화면을 떠나 끝난 한 장 해상도 올리기 - 비교해 보고 저장
|
|
if (AiJobs.instance.lastDoneView case final view?) TextButton(onPressed: view, child: Text(tr('보기'))),
|
|
if (AiJobs.instance.lastDoneDir case final dir?)
|
|
TextButton(onPressed: () => openFolderInApp(Navigator.of(context), c, dir), child: Text(tr('열기'))),
|
|
]),
|
|
if (!c.busy && !AiJobs.instance.busy)
|
|
_Dim(tr('쉬는 중'))
|
|
else if (c.busy) ...[
|
|
Text(
|
|
'▶ ${c.currentJob ?? tr('작업 중')}',
|
|
style: const TextStyle(fontSize: 12, color: JjColors.accent),
|
|
),
|
|
for (final (i, j) in c.pendingJobs.indexed)
|
|
Text(
|
|
trf('{0}. {1} (대기)', [i + 1, j]),
|
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
];
|
|
|
|
Widget _title(String t, {Widget? action}) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
t,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: JjColors.textDim,
|
|
),
|
|
),
|
|
),
|
|
?action,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Dim extends StatelessWidget {
|
|
final String text;
|
|
const _Dim(this.text);
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _VideoRow extends StatelessWidget {
|
|
final VideoItem v;
|
|
const _VideoRow({required this.v});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (icon, color) = switch (v.status) {
|
|
JobStatus.ready => (Icons.movie_outlined, JjColors.textDim),
|
|
JobStatus.running => (Icons.autorenew, JjColors.accent),
|
|
JobStatus.done => (Icons.check_circle, JjColors.success),
|
|
JobStatus.failed => (Icons.error, JjColors.danger),
|
|
JobStatus.cancelled => (Icons.cancel_outlined, JjColors.textDim), // 187
|
|
};
|
|
final sub = v.status == JobStatus.running
|
|
? '${v.phase ?? ''} ${(v.progress * 100).round()}%'
|
|
: v.phase ??
|
|
switch (v.status) {
|
|
JobStatus.done => tr('MKV 완성'),
|
|
JobStatus.failed => trf('실패: {0}', [v.message ?? '']),
|
|
JobStatus.cancelled => tr('취소됨'),
|
|
_ => trf('자막 {0}개', [v.subtitles.where((s) => s.enabled).length]),
|
|
};
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 16, color: color),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
v.fileName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
if (v.status == JobStatus.running)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: LinearProgressIndicator(
|
|
value: v.progress <= 0 ? null : v.progress,
|
|
minHeight: 3,
|
|
),
|
|
),
|
|
Text(
|
|
sub,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: v.status == JobStatus.failed
|
|
? JjColors.danger
|
|
: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DownloadRow extends StatelessWidget {
|
|
final AppController c;
|
|
final DownloadTask t;
|
|
const _DownloadRow({required this.c, required this.t});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final files = t.state == DownloadState.done
|
|
? DownloadManager.videoFilesOf(t)
|
|
: const <String>[];
|
|
final inList =
|
|
files.isNotEmpty &&
|
|
files.every(
|
|
(f) => c.videos.any((v) => v.path.toLowerCase() == f.toLowerCase()),
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
t.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
const SizedBox(height: 2),
|
|
if (t.state != DownloadState.cancelled)
|
|
DownloadProgressBar(t: t, height: 12),
|
|
],
|
|
),
|
|
),
|
|
if (files.isNotEmpty)
|
|
IconButton(
|
|
tooltip: inList ? tr('편집 목록에 있음') : tr('편집 목록에 추가'),
|
|
iconSize: 16,
|
|
visualDensity: VisualDensity.compact,
|
|
icon: Icon(
|
|
inList ? Icons.playlist_add_check : Icons.playlist_add,
|
|
color: inList ? JjColors.success : JjColors.accent,
|
|
),
|
|
onPressed: inList ? null : () => c.addDownloaded(files),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 지금 하는 작업 + 대기 개수 (상단 바 · 브라우저에서 같이 씀)
|
|
class JobIndicator extends StatelessWidget {
|
|
final AppController c;
|
|
final bool compact;
|
|
|
|
/// 돌아가는 표시만 (글은 마우스를 올리면)
|
|
final bool iconOnly;
|
|
const JobIndicator({
|
|
super.key,
|
|
required this.c,
|
|
this.compact = false,
|
|
this.iconOnly = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!c.busy) return const SizedBox();
|
|
final running = c.videos
|
|
.where((v) => v.status == JobStatus.running)
|
|
.toList();
|
|
final p = running.isEmpty
|
|
? null
|
|
: running.map((v) => v.progress).reduce((a, b) => a + b) /
|
|
running.length;
|
|
final wait = c.pendingJobs.length;
|
|
// 누르면 작업 현황 화면 (어느 화면에서나)
|
|
final scope = AppScope.maybeOf(context);
|
|
final nav = Navigator.maybeOf(context);
|
|
final body = Tooltip(
|
|
message: [
|
|
trf('지금: {0}', [c.currentJob ?? '']),
|
|
for (final v in running)
|
|
' · ${v.fileName} — ${v.phase ?? ''} ${(v.progress * 100).round()}%',
|
|
if (wait > 0) trf('대기 {0}개:', [wait]),
|
|
for (final j in c.pendingJobs) ' · $j',
|
|
].join('\n'),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
value: p == null || p == 0 ? null : p,
|
|
),
|
|
),
|
|
// 좁아서 아이콘만일 때도 진행률은 보이게. 여러 개면 짧은 꼴 "2/3 · 37%" (폰 세로에서 "MKV 3개 중 2번째" 가 잘렸다).
|
|
// 폭은 늘 같게 잡아 둔다 - 위 막대는 작업이 시작될 때 재는데, 그때는 진행률이 없어 글이 없다가 나중에 생기면
|
|
// 잰 폭보다 넓어져 왼쪽의 [+ 동영상 추가] 를 가렸다 (a4 934, 폰 세로)
|
|
if (iconOnly) ...[
|
|
const SizedBox(width: 4),
|
|
SizedBox(
|
|
width: c.batchTotal > 1 ? 72 : 36,
|
|
child: Text(
|
|
[
|
|
if (c.batchTotal > 1) '${c.batchStarted}/${c.batchTotal}',
|
|
if (p != null) '${(p * 100).round()}%',
|
|
].join(' · '),
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
overflow: TextOverflow.fade,
|
|
style: const TextStyle(fontSize: 12, color: JjColors.accent),
|
|
),
|
|
),
|
|
],
|
|
if (!iconOnly) const SizedBox(width: 6),
|
|
if (!iconOnly)
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: compact ? 160 : 240),
|
|
child: Text(
|
|
// P0-4: 여러 개를 만들 때 한 개가 끝나면 81% → 36% 로 내려가 헷갈렸다 - "3개 중 2번째 · 36%" 처럼 지금 것의 진행
|
|
c.batchTotal > 1
|
|
? '${trf('MKV {0}개 중 {1}번째', [c.batchTotal, c.batchStarted])}${p == null ? '' : ' · ${(p * 100).round()}%'}'
|
|
'${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}'
|
|
: '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12, color: JjColors.accent),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (scope == null || nav == null) return body;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(6),
|
|
onTap: () => WorkStatusPage.open(nav, scope.controller, scope.downloads),
|
|
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: body),
|
|
);
|
|
}
|
|
}
|