JJ_MKVMaker/lib/ui/home/home_top_bar.dart

400 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/// 약속: 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!),
],
],
),
);
});
}
}