400 lines
21 KiB
Dart
400 lines
21 KiB
Dart
/// 약속: 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!),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
});
|
||
}
|
||
}
|