좁은 화면 (접은 폴드 · 휴대폰 세로) 배치 정리

- AppTopBar: 폭 720 미만이면 위쪽 막대를 두 줄로 (위: 화면 이동 · 화면 크기 · 설정 · 종료, 아래: 화면의 도구).
  모든 화면 (MKV · 브라우저 · 탐색기 · Rsync · 모니터링 · 다운로드 · 설정 · 플레이어 · 자막 편집 · 작업 현황 · 즐겨찾기) 에 적용.
  접은 폴드에서 설정 · 종료 버튼이 화면 밖으로 밀려 누를 수 없던 것.
- MKV 화면: 좁으면 목록 위 · 자세히 보기 아래, 변환 설정 줄은 옆으로 밀어 보기, 자막 버튼은 줄바꿈.
- 대화 상자 37개: 내용이 화면보다 길면 스크롤 (가로 폴드에서 버튼이 내용과 겹치던 것).
- SettingTile: 좁은 화면에서 설정 줄의 고르기 상자 · 입력 칸을 설명 아래로.
- 다운로드 도구 줄 옆으로 밀어 보기, 빈 목록 안내 글 여백.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 14:10:04 +09:00
parent 44ae09a31f
commit 2553107aa5
21 changed files with 387 additions and 219 deletions

View File

@ -17,6 +17,46 @@ import '../l10n/tr.dart';
const double appBarHeight = 56;
const double appBarRightPadding = 12;
/// 이보다 좁으면 (접은 폴드 · 휴대폰 세로 등) 위쪽 막대를 두 줄로: 위 = 화면 이동 · 설정 · 종료, 아래 = 화면의 도구
const double compactWidth = 720;
bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compactWidth;
/// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions].
/// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다.
class AppTopBar extends StatelessWidget {
final Widget nav;
final Widget middle;
final Widget actions;
const AppTopBar({super.key, required this.nav, required this.middle, required this.actions});
@override
Widget build(BuildContext context) {
if (!isCompact(context)) {
return Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [nav, const SizedBox(width: 8), Expanded(child: middle), actions]),
);
}
return Container(
color: JjColors.panel,
padding: const EdgeInsets.only(left: 4, right: 4),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// 아주 좁으면 왼쪽 이동 버튼 묶음을 줄여서라도 설정 · 종료가 밀려나지 않게
SizedBox(
height: 48,
child: Row(children: [
Expanded(child: Align(alignment: Alignment.centerLeft, child: FittedBox(fit: BoxFit.scaleDown, child: nav))),
actions,
]),
),
SizedBox(height: 48, child: Padding(padding: const EdgeInsets.only(left: 6), child: middle)),
]),
);
}
}
/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다
class AppScope extends InheritedWidget {
final AppController controller;
@ -321,9 +361,10 @@ class AppActions extends StatelessWidget {
final exit = onExit ?? scope?.onExit;
if (controller == null && exit == null) return const SizedBox();
final usage = controller?.usage;
final compact = isCompact(context);
return Row(mainAxisSize: MainAxisSize.min, children: [
// CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준)
if (usage != null && showUsage) UsageView(usage: usage),
if (usage != null && showUsage && !compact) UsageView(usage: usage),
// 화면 크기: − 100% +
if (controller != null) ScaleButtons(c: controller),
const SizedBox(width: 4),

View File

@ -160,6 +160,7 @@ Future<void> showNewBookmarkDialog(BuildContext context, BookmarksController bm,
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('즐겨찾기 추가')),
content: SizedBox(
width: 440,
@ -204,6 +205,7 @@ Future<String?> askText(BuildContext context, String title, String label, String
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(title),
content: SizedBox(
width: 380,
@ -468,17 +470,16 @@ class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
},
);
Widget _topBar() => Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Widget _topBar() => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(children: [
const Icon(Icons.bookmarks_outlined, color: JjColors.accent),
const SizedBox(width: 8),
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 24),
if (!isCompact(context)) ...[
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 24),
],
Expanded(
child: TextField(
controller: _search,
@ -530,7 +531,6 @@ class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
}
},
),
const AppActions(),
]),
);

View File

@ -516,15 +516,16 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
icon: Icon(i, size: 20, color: color),
onPressed: f,
);
return Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(onBrowserPage: true),
const SizedBox(width: 8),
Container(width: 1, height: 24, color: JjColors.border),
const SizedBox(width: 4),
// 좁은 화면: 둘째 줄에 페이지 이동 · 주소 · 다운로드 (외부 브라우저 · 작업 현황은 빼고, 다운로드는 아이콘만)
final compact = isCompact(context);
return AppTopBar(
nav: const AppNavButtons(onBrowserPage: true),
actions: AppActions(c: widget.c),
middle: Row(children: [
if (!compact) ...[
Container(width: 1, height: 24, color: JjColors.border),
const SizedBox(width: 4),
],
// 웹 페이지 이동 (앱 화면 이동인 왼쪽 버튼과 구분되는 모양)
btn(Icons.arrow_back_ios_new, tr('이전 페이지 (Alt+←)'), _canBack ? () => _nav?.back() : null),
btn(Icons.arrow_forward_ios, tr('다음 페이지 (Alt+→)'), _canFwd ? () => _nav?.forward() : null),
@ -565,14 +566,22 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
() => _setTranslate(!_trOn, force: true),
color: _trOn ? JjColors.accent : null),
const SizedBox(width: 4),
FilledButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
label: Text(tr('다운로드')),
),
if (compact)
IconButton.filled(
tooltip: tr('다운로드'),
visualDensity: VisualDensity.compact,
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
)
else
FilledButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
label: Text(tr('다운로드')),
),
const SizedBox(width: 4),
btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
if (widget.c.busy && !_work)
if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
if (widget.c.busy && !_work && !compact)
InkWell(
onTap: _toggleWork,
borderRadius: BorderRadius.circular(6),
@ -581,11 +590,11 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
child: JobIndicator(c: widget.c, compact: true),
),
),
btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, tr('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'),
_toggleWork, color: widget.c.busy ? JjColors.accent : null),
if (!compact)
btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, tr('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'),
_toggleWork, color: widget.c.busy ? JjColors.accent : null),
btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, tr('즐겨찾기 관리 (Ctrl+Shift+B)'),
() => setState(() => _panel = !_panel)),
AppActions(c: widget.c),
]),
);
}

View File

@ -12,6 +12,7 @@ Future<bool> chooseChromeEngine(BuildContext context, AppController c) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('내장 Chrome 엔진 내려받기')),
content: SizedBox(
width: 460,
@ -40,6 +41,7 @@ Future<bool> chooseChromeEngine(BuildContext context, AppController c) async {
final restart = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('다시 시작')),
content: Text(tr('Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?')),
actions: [
@ -97,6 +99,7 @@ class _InstallProgressState extends State<_InstallProgress> {
@override
Widget build(BuildContext context) => AlertDialog(
scrollable: true,
title: Text(tr('내장 Chrome 엔진')),
content: SizedBox(
width: 420,

View File

@ -14,6 +14,7 @@ import 'folder_picker.dart';
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
import 'setting_tile.dart';
/// 환경 설정의 복사 · 동기화 부분.
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
@ -58,7 +59,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin';
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값)
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => ListTile(
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => SettingTile(
title: Text(title),
subtitle: Text(hint),
trailing: SizedBox(
@ -95,7 +96,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
return Column(children: [
const Divider(height: 1),
if (desk) ...[
ListTile(
SettingTile(
title: Text(tr('파일 복사 · 이동 방법')),
subtitle: Text(tr('파일만 골랐을 때')),
trailing: DropdownButton<String>(
@ -104,7 +105,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!),
),
),
ListTile(
SettingTile(
title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>(
@ -116,7 +117,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
_options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions,
defaultRobocopyOptions, (x, v) => x.robocopyOptions = v),
],
ListTile(
SettingTile(
title: Text(tr('속도 제한 (KB/s)')),
subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. '
'옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)')),
@ -142,7 +143,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
return Column(children: [
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
ListTile(
SettingTile(
title: Text(tr('rsync 가져오기')),
subtitle: Text(_checking
? tr('확인 중…')
@ -187,7 +188,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
]),
),
if (s.rsyncSource == 'custom')
ListTile(
SettingTile(
title: Text(tr('rsync 실행 파일')),
subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath),
trailing: TextButton(
@ -226,6 +227,7 @@ class _LiveSyncTile extends StatelessWidget {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('실시간 동기화 추가')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$src\n→ $dst', style: const TextStyle(fontSize: 13)),
@ -242,7 +244,7 @@ class _LiveSyncTile extends StatelessWidget {
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
),
ListTile(
SettingTile(
contentPadding: EdgeInsets.zero,
leading: WeekGridPreview(lines: schedule, width: 96),
title: Text(scheduleSummary(schedule)),
@ -273,7 +275,7 @@ class _LiveSyncTile extends StatelessWidget {
final live = LiveSync.instance;
final pairs = c.settings.liveSyncPairs;
Widget body() => Column(children: [
ListTile(
SettingTile(
title: Text(tr('실시간 동기화 (lsyncd 처럼)')),
subtitle: Text(Platform.isWindows
? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.')
@ -288,7 +290,7 @@ class _LiveSyncTile extends StatelessWidget {
for (var i = 0; i < pairs.length; i++)
Padding(
padding: const EdgeInsets.only(left: 16),
child: ListTile(
child: SettingTile(
dense: true,
leading: Icon(Icons.sync, color: pairs[i].enabled ? JjColors.accent : JjColors.textDim),
title: Text('${pairs[i].source} → ${pairs[i].target}', maxLines: 2, overflow: TextOverflow.ellipsis),
@ -389,7 +391,7 @@ class LiveSyncRunOptions extends StatelessWidget {
title: Text(tr('백그라운드로 실행')),
subtitle: Text(backgroundHelp()),
),
ListTile(
SettingTile(
dense: true,
title: Text(tr('앱을 다시 켤 때 동기화')),
subtitle: Text(tr('바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로')),

View File

@ -76,16 +76,13 @@ class _DownloadsPageState extends State<DownloadsPage> {
final allSel = d.tasks.isNotEmpty && d.selected.length == d.tasks.length;
return Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(onDownloadsPage: true),
const SizedBox(width: 8),
AppTopBar(
nav: const AppNavButtons(onDownloadsPage: true),
actions: const AppActions(),
middle: Row(children: [
Text(trf('다운로드 ({0})', [d.tasks.length]),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 20),
SizedBox(width: isCompact(context) ? 8 : 20),
Expanded(
child: TextField(
controller: _url,
@ -103,7 +100,6 @@ class _DownloadsPageState extends State<DownloadsPage> {
),
const SizedBox(width: 8),
FilledButton(onPressed: _add, child: Text(tr('추가'))),
const AppActions(),
]),
),
Container(
@ -126,7 +122,12 @@ class _DownloadsPageState extends State<DownloadsPage> {
),
]),
),
const Spacer(),
// 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 끝부터 보임)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(children: [
if (d.addToEditList != null) ...[
_btn(Icons.playlist_add, tr('동영상 추가'),
hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null),
@ -158,18 +159,23 @@ class _DownloadsPageState extends State<DownloadsPage> {
const SizedBox(width: 12),
_btn(Icons.cleaning_services_outlined, tr('완료 정리'),
d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null),
]),
),
),
]),
),
const Divider(height: 1),
Expanded(
child: d.tasks.isEmpty
? Center(
? Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
Platform.isAndroid
? tr('다운로드가 없습니다.\nYouTube 주소 · 마그넷 링크를 위 칸에 붙여 넣거나, 웹 브라우저에서 [다운로드] 를 누르세요.')
: tr('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.'),
textAlign: TextAlign.center,
style: const TextStyle(color: JjColors.textDim)))
style: const TextStyle(color: JjColors.textDim))))
: ListView.builder(
itemCount: d.tasks.length,
itemBuilder: (_, i) => _DownloadRow(d: d, t: d.tasks[i]),
@ -206,6 +212,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('삭제')),
content: Text(trf('선택한 {0}개를 목록에서 삭제합니다.\n받는 중인 항목은 중지하고 받던 파일도 지웁니다. (완료된 파일은 남습니다)', [n])),
actions: [
@ -366,6 +373,7 @@ class _ExitDialog extends StatelessWidget {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('종료')),
content: Text(tr('다운로드를 종료하시겠습니까?')),
actions: [

View File

@ -555,6 +555,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('WebDAV 에서 받는 중')),
content: ValueListenableBuilder<(int, int)>(
valueListenable: progress,
@ -860,6 +861,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('삭제')),
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
content: Text(paths.length == 1
@ -983,6 +985,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(move ? tr('이동') : tr('복사')),
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}'
'\n\n${trf('방법: {0}', [tr(method.label)])}'
@ -1069,6 +1072,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
content: SizedBox(
width: 560,
@ -1280,6 +1284,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(title),
content: TextField(
controller: ctl,
@ -1304,6 +1309,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(e.name),
content: SelectableText([
e.path,
@ -1337,6 +1343,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('정렬 기준')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Wrap(spacing: 6, children: [
@ -1407,6 +1414,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
]),
]);
return AlertDialog(
scrollable: true,
title: Text(tr('창 배치')),
content: SizedBox(
width: 420,
@ -1557,13 +1565,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
);
}
Widget _topBar() => Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
const SizedBox(width: 8),
Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c),
middle: Row(children: [
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
@ -1586,7 +1591,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
],
),
AppActions(c: c),
]),
);

View File

@ -55,18 +55,34 @@ class HomePage extends StatelessWidget {
_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!),
),
],
),
// 좁은 화면 (접은 폴드 · 휴대폰 세로): 목록을 위에, 자세히 보기를 아래에
child: isCompact(context)
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.32).clamp(160.0, 420.0),
child: _VideoList(c: c)),
const Divider(height: 1),
Expanded(
child: c.selected == null
? const _EmptyHint()
: _VideoDetail(c: c, v: c.selected!),
),
],
)
: 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)
@ -121,6 +137,7 @@ class _TopBar extends StatelessWidget {
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: [
@ -145,6 +162,7 @@ class _TopBar extends StatelessWidget {
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;
@ -152,18 +170,20 @@ class _TopBar extends StatelessWidget {
// 아주 좁을 때: 다운로드 상자 숨김 (왼쪽 공통 버튼으로 열 수 있음) → MKV 만들기도 아이콘만
var dlBox = downloads != null, mkvText = true;
// 각 부분의 너비 (실제 화면에서 잰 값)
// 좁은 화면: 이동 버튼 · 설정 · 종료는 첫 줄에 있으므로 둘째 줄 (가운데 도구) 만 잰다
double need() =>
8 + appBarRightPadding + AppNavButtons.width + 8 + (title ? 126 : 0) + 24 +
(compact ? 0 : 8 + appBarRightPadding + AppNavButtons.width + 8) + (title ? 126 : 0) + 24 +
(leftText ? 153 + (hasAi ? 161 + 298 : 0) : 40 + (hasAi ? 96 : 0)) + 8 +
(c.busy ? (jobText ? 350 : 60) : 0) +
(hasPlayer ? (rightText ? 201 : 48) : 0) +
(mkvText ? 166 + (c.busy && jobText ? 60 : 0) : 48) +
(rightText ? 166 : 48) + // 결과 폴더
(dlBox ? 162 : 0) +
(usage ? 220 : 0) + 96 + 92 +
(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;
@ -189,17 +209,15 @@ class _TopBar extends StatelessWidget {
);
}
return Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(
return AppTopBar(
// 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록]
nav: const AppNavButtons(),
// 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리
actions: AppActions(c: c, onExit: onExit, showUsage: usage),
middle: Row(
children: [
// 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록]
const AppNavButtons(),
const SizedBox(width: 8),
if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
const SizedBox(width: 24),
SizedBox(width: compact ? 0 : 24),
// 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게
Expanded(
child: SingleChildScrollView(
@ -271,8 +289,6 @@ class _TopBar extends StatelessWidget {
const SizedBox(width: 12),
_DownloadBox(d: downloads!),
],
// 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리
AppActions(c: c, onExit: onExit, showUsage: usage),
],
),
);
@ -369,11 +385,8 @@ class _EncodeBar extends StatelessWidget {
onChanged: locked ? null : (v) => onChanged(v as T),
);
return Container(
height: 44,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
final compact = isCompact(context);
final row = Row(children: [
label(tr('화면 크기')),
drop(s.resolution, ResolutionChoice.values, (r) => r.label, c.setResolution),
const SizedBox(width: 20),
@ -396,7 +409,7 @@ class _EncodeBar extends StatelessWidget {
),
),
const SizedBox(width: 12),
Expanded(
if (!compact) Expanded(
child: Text(
s.adjusts
? s.adjustSummary
@ -408,7 +421,12 @@ class _EncodeBar extends StatelessWidget {
fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim),
),
),
]),
]);
return Container(
height: 44,
color: JjColors.panel,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 16),
child: compact ? SingleChildScrollView(scrollDirection: Axis.horizontal, child: row) : row,
);
}
}
@ -751,8 +769,9 @@ class _VideoDetail extends StatelessWidget {
}
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) {
final side = isCompact(context) ? 12.0 : 20.0;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 88),
padding: EdgeInsets.fromLTRB(side, side, side, 88),
children: [
Row(children: [
Expanded(
@ -799,30 +818,33 @@ class _VideoDetail extends StatelessWidget {
),
],
const SizedBox(height: 24),
Row(children: [
Text(tr('자막'),
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: Text(tr('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: Text(tr('인터넷 자막 찾기')),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: locked ? null : () => c.pickSubtitlesFor(v),
icon: const Icon(Icons.subtitles_outlined, size: 18),
label: Text(tr('자막 파일 추가')),
// 좁으면 버튼이 다음 줄로 (오른쪽 정렬)
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('자막 파일 추가')),
),
]),
),
]),
const SizedBox(height: 8),

View File

@ -5,6 +5,7 @@ import '../core/languages.dart';
import '../l10n/tr.dart';
import '../services/ai_services.dart';
import 'theme.dart';
import 'setting_tile.dart';
/// 환경 설정 맨 위: 화면 언어 고르기 · 언어 추가 (AI 자동 번역) · 더한 언어 삭제
class LanguageSettings extends StatelessWidget {
@ -16,7 +17,7 @@ class LanguageSettings extends StatelessWidget {
builder: (context, _) {
final added = i18n.available.where((c) => !I18nController.builtIn.contains(c)).toList();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ListTile(
SettingTile(
leading: const Icon(Icons.translate, color: JjColors.accent),
// 읽을 수 없는 언어를 골라도 찾을 수 있게 영어를 함께
title: Text(uiLanguage == 'en' ? 'Language' : '${tr('화면 언어')} · Language'),
@ -45,7 +46,7 @@ class LanguageSettings extends StatelessWidget {
]),
),
for (final code in added)
ListTile(
SettingTile(
dense: true,
contentPadding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
title: Text(I18nController.nativeName(code)),
@ -73,7 +74,7 @@ Future<void> addLanguageDialog(BuildContext context) async {
height: 420,
child: ListView(children: [
for (final l in i18n.addable)
ListTile(
SettingTile(
title: Text(I18nController.nativeName(l.code)),
subtitle: Text('${l.name} (${l.code})'),
onTap: () => Navigator.pop(ctx, l),
@ -91,6 +92,7 @@ Future<void> addLanguageDialog(BuildContext context) async {
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('{0} 추가', [I18nController.nativeName(pick.code)])),
content: ValueListenableBuilder<(String, double)>(
valueListenable: state,

View File

@ -34,28 +34,31 @@ class MonitorPage extends StatelessWidget {
initialIndex: initialTab,
child: Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
Expanded(
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(icon: const Icon(Icons.copy_all_outlined, size: 18), text: tr('복사 · rsync')),
Tab(icon: const Icon(Icons.sync, size: 18), text: tr('lsync (실시간 동기화)')),
],
Builder(builder: (context) {
// 좁은 화면: 둘째 줄 (높이 48) 에 맞게 탭에 아이콘을 옆으로
final compact = isCompact(context);
Tab tab(IconData icon, String text) => compact
? Tab(height: 46, child: Row(mainAxisSize: MainAxisSize.min, children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(text)]))
: Tab(icon: Icon(icon, size: 18), text: text);
return AppTopBar(
nav: const AppNavButtons(),
actions: AppActions(c: c),
middle: Row(children: [
Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
Expanded(
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
tab(Icons.copy_all_outlined, tr('복사 · rsync')),
tab(Icons.sync, tr('lsync (실시간 동기화)')),
],
),
),
),
AppActions(c: c),
]),
),
]),
);
}),
Expanded(
child: TabBarView(children: [
_CopyTab(c: c),
@ -182,8 +185,11 @@ class _CopyTab extends StatelessWidget {
final tasks = center.tasks;
if (tasks.isEmpty) {
return Center(
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
style: const TextStyle(color: JjColors.textDim)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)),
),
);
}
return ListView(
@ -281,6 +287,7 @@ class _CopyCardState extends State<_CopyCard> {
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('옵션을 저장했습니다')),
content: Text(running
? tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
@ -307,6 +314,7 @@ class _CopyCardState extends State<_CopyCard> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('lsync 로 이동')),
content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
actions: [
@ -509,6 +517,7 @@ class _LiveTabState extends State<_LiveTab> {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('최종 정리')),
content: SizedBox(
width: 600,

View File

@ -272,25 +272,21 @@ class _PlayerPageState extends State<PlayerPage> {
);
}
Widget _header() => Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Expanded(
child: ValueListenableBuilder<PlayerState>(
valueListenable: pl.state,
builder: (_, s, _) => Text(
'${p.basename(_currentFile)} (${s.index + 1}/${pl.playlist.length})',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
Widget _header() => AppTopBar(
nav: const AppNavButtons(),
actions: AppActions(c: c),
middle: Align(
alignment: Alignment.centerLeft,
child: ValueListenableBuilder<PlayerState>(
valueListenable: pl.state,
builder: (_, s, _) => Text(
'${p.basename(_currentFile)} (${s.index + 1}/${pl.playlist.length})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
AppActions(c: c),
]),
),
);
Widget _videoArea() {

View File

@ -10,6 +10,7 @@ Future<String?> installRsyncWithDialog(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('rsync 내려받기')),
content: Text(tr('rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n'
'설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?')),
@ -25,6 +26,7 @@ Future<String?> installRsyncWithDialog(BuildContext context) async {
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
scrollable: true,
title: Text(tr('rsync 받는 중')),
content: ValueListenableBuilder<(double, String)>(
valueListenable: progress,

64
lib/ui/setting_tile.dart Normal file
View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'app_actions.dart' show isCompact;
/// 환경 설정의 한 줄 (ListTile 과 같은 쓰임새).
/// 좁은 화면 (접은 폴드 · 휴대폰 세로) 에서는 오른쪽의 넓은 조절 (고르기 상자 · 입력 칸 · 버튼) 을 설명 아래로 내려
/// 제목 · 설명이 한두 글자 폭으로 눌리지 않게 한다. 스위치 · 체크 상자 · 아이콘 버튼은 그대로 오른쪽에.
class SettingTile extends StatelessWidget {
final Widget? leading;
final Widget? title;
final Widget? subtitle;
final Widget? trailing;
final bool isThreeLine;
final bool enabled;
final bool? dense;
final VoidCallback? onTap;
final EdgeInsetsGeometry? contentPadding;
const SettingTile({
super.key,
this.leading,
this.title,
this.subtitle,
this.trailing,
this.isThreeLine = false,
this.enabled = true,
this.dense,
this.onTap,
this.contentPadding,
});
static bool _small(Widget w) => w is Switch || w is Checkbox || w is Icon || w is IconButton || w is Radio;
@override
Widget build(BuildContext context) {
final t = trailing;
if (t == null || _small(t) || !isCompact(context)) {
return ListTile(
leading: leading,
title: title,
subtitle: subtitle,
trailing: t,
isThreeLine: isThreeLine,
enabled: enabled,
dense: dense,
onTap: onTap,
contentPadding: contentPadding,
);
}
return ListTile(
leading: leading,
title: title,
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
?subtitle,
const SizedBox(height: 6),
Align(alignment: Alignment.centerLeft, child: t),
]),
enabled: enabled,
dense: dense,
onTap: onTap,
contentPadding: contentPadding,
);
}
}

View File

@ -27,6 +27,7 @@ import 'webdav_settings.dart';
import 'explorer_look.dart' show ExplorerStyle;
import 'theme.dart';
import '../l10n/tr.dart';
import 'setting_tile.dart';
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
class CountSelector extends StatelessWidget {
@ -82,6 +83,7 @@ class _CountInputDialogState extends State<_CountInputDialog> {
@override
Widget build(BuildContext context) => AlertDialog(
scrollable: true,
title: Text(tr('직접 입력')),
content: TextField(
controller: _ctrl,
@ -122,7 +124,7 @@ class _ExternalPlayers extends StatelessWidget {
: map.entries.map((e) => '${e.key}: ${e.value == 'system' ? tr('기본 프로그램') : e.value.split(RegExp(r'[\\/]')).last}').join(', ')),
children: [
for (final ext in videoExtensions)
ListTile(
SettingTile(
dense: true,
title: Text('.$ext'),
trailing: DropdownButton<String>(
@ -197,7 +199,7 @@ class _ContextMenuTileState extends State<_ContextMenuTile> {
}
@override
Widget build(BuildContext context) => ListTile(
Widget build(BuildContext context) => SettingTile(
title: Text(tr('탐색기 오른쪽 클릭 메뉴')),
subtitle: Text(_registered == true
? tr('등록됨: "JJ_MKVMaker 로 재생" · "JJ_MKVMaker 로 자막 만들기" (Windows 11: 더 많은 옵션 표시)')
@ -255,36 +257,35 @@ class _SettingsPageState extends State<SettingsPage> {
final desk = !Platform.isAndroid;
return Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
AppTopBar(
nav: const AppNavButtons(),
actions: AppActions(c: c, onSettingsPage: true),
middle: Row(children: [
Text(tr('환경 설정'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
Text(tr('바꾸면 바로 저장됩니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Flexible(
child: Text(tr('바꾸면 바로 저장됩니다'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
const SizedBox(width: 8),
AppActions(c: c, onSettingsPage: true),
]),
),
// 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 0),
padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0),
child: _groupBar(desk),
),
Expanded(
// 모든 묶음을 한 번에 그린다 (ListView 는 화면 밖 묶음을 그리지 않아 바로가기로 갈 수 없었음)
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
padding: EdgeInsets.fromLTRB(isCompact(context) ? 4 : 24, 4, isCompact(context) ? 4 : 24, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
_group('general', Icons.tune, tr('일반'), [
// 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위)
const LanguageSettings(),
_appIconTile(c, desk),
if (c.services.updater != null) ...[
ListTile(
SettingTile(
leading: const Icon(Icons.system_update_alt, color: JjColors.accent),
title: FutureBuilder<String>(
future: c.services.updater!.currentVersion(),
@ -311,7 +312,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')),
),
],
ListTile(
SettingTile(
title: Text(tr('홈 화면')),
subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')),
trailing: DropdownButton<String>(
@ -327,7 +328,7 @@ class _SettingsPageState extends State<SettingsPage> {
]),
_group('display', Icons.aspect_ratio, tr('화면'), [
if (!desk)
ListTile(
SettingTile(
title: Text(tr('화면 방향')),
subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')),
trailing: DropdownButton<String>(
@ -343,7 +344,7 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
),
ListTile(
SettingTile(
title: Text(tr('기본 화면 크기')),
subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])),
trailing: DropdownButton<double>(
@ -366,7 +367,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
]),
_group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [
ListTile(
SettingTile(
title: Text(tr('동시 MKV 변환 수')),
subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')),
trailing: CountSelector(
@ -374,7 +375,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n),
),
),
ListTile(
SettingTile(
title: Text(tr('마지막 인코딩 설정을 기억합니다')),
subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])),
),
@ -432,7 +433,7 @@ class _SettingsPageState extends State<SettingsPage> {
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true),
]),
_group('play', Icons.play_circle_outline, tr('재생'), [
ListTile(
SettingTile(
title: Text(tr('동영상 하나를 재생할 때')),
subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')),
trailing: DropdownButton<PlaylistMode>(
@ -443,7 +444,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!),
),
),
ListTile(
SettingTile(
title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')),
subtitle: Text(desk
? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)')
@ -458,7 +459,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
if (desk) ...[
ListTile(
SettingTile(
title: Text(tr('탐색기에서 연 동영상을 재생할 창')),
subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')),
trailing: DropdownButton<String>(
@ -509,7 +510,7 @@ class _SettingsPageState extends State<SettingsPage> {
'끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')),
),
if (desk) ...[
ListTile(
SettingTile(
title: Text(tr('브라우저 엔진 (앱 안)')),
subtitle: Text(
trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])),
@ -544,7 +545,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
]),
),
ListTile(
SettingTile(
title: Text(tr('외부 브라우저')),
subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')),
trailing: DropdownButton<String>(
@ -566,7 +567,7 @@ class _SettingsPageState extends State<SettingsPage> {
],
]),
_group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [
ListTile(
SettingTile(
title: Text(tr('누르기')),
subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). '
'Ctrl + 클릭: 여러 개 고르기')),
@ -579,7 +580,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!),
),
),
ListTile(
SettingTile(
title: Text(tr('스타일')),
subtitle: Text(tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · '
'Total Commander (촘촘한 목록 · [폴더])')),
@ -592,7 +593,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!),
),
),
ListTile(
SettingTile(
title: Text(tr('창 배치')),
subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')),
trailing: DropdownButton<String>(
@ -628,7 +629,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('완료시 자동 동영상추가')),
subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
),
ListTile(
SettingTile(
title: Text(tr('YouTube 받을 형식')),
subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')),
trailing: DropdownButton<YtContainer>(
@ -637,7 +638,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!),
),
),
ListTile(
SettingTile(
enabled: !s.ytContainer.audioOnly,
title: Text(tr('YouTube 화질')),
subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')),
@ -653,7 +654,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('재생목록 주소면 목록 전체 받기')),
subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')),
),
ListTile(
SettingTile(
title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')),
subtitle: Text(s.ytCookiesFile.isNotEmpty
? 'cookies.txt: ${s.ytCookiesFile}'
@ -694,7 +695,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
]),
),
ListTile(
SettingTile(
title: Text(tr('동시 다운로드 수')),
subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')),
trailing: CountSelector(
@ -715,7 +716,7 @@ class _SettingsPageState extends State<SettingsPage> {
]),
if (desk)
_group('run', Icons.power_settings_new, tr('실행 · 종료'), [
ListTile(
SettingTile(
title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')),
subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])),
isThreeLine: true,
@ -735,7 +736,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')),
subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')),
),
ListTile(
SettingTile(
title: Text(tr('창 보이기 / 숨기기 단축키')),
subtitle: _hotkeyError == null
? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다'))
@ -757,7 +758,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
]),
_group('cleanup', Icons.cleaning_services_outlined, tr('저장 공간 정리'), [
ListTile(
SettingTile(
title: Text(tr('임시 파일 · 남은 조각 정리')),
subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · '
'업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. '
@ -772,7 +773,7 @@ class _SettingsPageState extends State<SettingsPage> {
]),
_group('about', Icons.info_outline, tr('프로그램 정보'), [
if (desk) ...[
ListTile(
SettingTile(
title: Text(tr('필수 프로그램 점검')),
subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')),
trailing: OutlinedButton(
@ -781,7 +782,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
],
ListTile(
SettingTile(
title: Text(tr('오픈 소스 라이선스')),
subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
@ -813,7 +814,7 @@ class _SettingsPageState extends State<SettingsPage> {
final s = c.settings;
void update(List<MoveTarget> Function(List<MoveTarget>) f) =>
c.updateSettings((x) => x.moveTargets = f([...x.moveTargets]));
return ListTile(
return SettingTile(
key: ValueKey('move_${i}_${t.dir}'),
leading: const Icon(Icons.drive_file_move_outline, color: JjColors.accent),
title: TextFormField(
@ -916,7 +917,7 @@ class _SettingsPageState extends State<SettingsPage> {
/// 앱 아이콘 고르기: 그림을 누르면 바로 바뀐다
Widget _appIconTile(AppController c, bool desk) {
final now = appIconOf(c.settings.appIcon);
return ListTile(
return SettingTile(
title: Text(tr('앱 아이콘')),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
@ -969,7 +970,7 @@ class _SettingsPageState extends State<SettingsPage> {
Widget _textTile(String title, String value, String hint, ValueChanged<String> onSave,
{bool obscure = false}) =>
ListTile(
SettingTile(
title: Text(title),
subtitle: Text(hint),
trailing: SizedBox(
@ -993,7 +994,7 @@ class _SettingsPageState extends State<SettingsPage> {
required VoidCallback onPick,
required VoidCallback onReset,
}) =>
ListTile(
SettingTile(
title: Text(title),
subtitle: Text(value ?? defaultText, maxLines: 2, overflow: TextOverflow.ellipsis),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [

View File

@ -19,6 +19,7 @@ Future<void> checkRequiredTools(BuildContext context, AppShell shell, {bool quie
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('필수 프로그램 설치')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?')),
@ -41,6 +42,7 @@ Future<void> checkRequiredTools(BuildContext context, AppShell shell, {bool quie
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('내려받는 중')),
content: ValueListenableBuilder<(String, double)>(
valueListenable: progress,
@ -63,6 +65,7 @@ Future<void> checkRequiredTools(BuildContext context, AppShell shell, {bool quie
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설치 실패')),
content: SelectableText(trf('인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}', [error])),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],

View File

@ -186,6 +186,7 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('문자셋 경고')),
content: Text(trf('{0} 로 표현할 수 없는 글자 {1}개가 ' '"?" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)', [saveCharsets[e.saveCharset], lost])),
actions: [
@ -236,6 +237,7 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('저장하지 않은 변경')),
content: Text(tr('변경 내용을 저장하지 않고 나갈까요?')),
actions: [
@ -479,14 +481,11 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
);
}
Widget _toolbar(BuildContext context) => Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(
Widget _toolbar(BuildContext context) => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(
children: [
const AppNavButtons(),
const SizedBox(width: 4),
Expanded(
child: Text(
trf('자막 편집 · {0}', [_entry.displayName]),
@ -550,7 +549,6 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
]),
),
),
const AppActions(),
],
),
);

View File

@ -52,6 +52,7 @@ class _TranslateDialogState extends State<_TranslateDialog> {
final src = widget.s.language;
final extra = languages.where((l) => !_targets.contains(l) && l.code != src.code).toList();
return AlertDialog(
scrollable: true,
title: Text(tr('자막 번역')),
content: SizedBox(
width: 480,

View File

@ -263,6 +263,7 @@ Future<String?> _download(BuildContext context, Updater up, ReleaseInfo r) async
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
scrollable: true,
title: Text(trf('v{0} 받는 중', [r.version])),
content: ValueListenableBuilder<double>(
valueListenable: progress,
@ -317,6 +318,7 @@ Future<void> installRelease(BuildContext context, AppController c, ReleaseInfo r
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설치 준비 완료')),
content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [
r.version,
@ -347,6 +349,7 @@ Future<void> installRelease(BuildContext context, AppController c, ReleaseInfo r
final again = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설치 허용 필요')),
content: Text(tr('설정 화면에서 "이 출처의 앱 설치 허용" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. '
'받은 파일을 그대로 씁니다 (다시 받지 않음).')),
@ -395,6 +398,7 @@ Future<void> _androidRollback(BuildContext context, AppController c, Updater up,
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])),
content: SizedBox(
width: 560,
@ -432,6 +436,7 @@ Future<void> _androidRollback(BuildContext context, AppController c, Updater up,
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('저장했습니다')),
content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])),
actions: [

View File

@ -37,6 +37,7 @@ Future<void> checkVersionRestore(
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설정 되살리기')),
content: SizedBox(width: 520, child: Text(message!)),
actions: [

View File

@ -4,6 +4,7 @@ import '../app/app_controller.dart';
import '../core/webdav.dart';
import '../l10n/tr.dart';
import 'theme.dart';
import 'setting_tile.dart';
/// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다.
@ -15,7 +16,7 @@ class WebDavSettings extends StatelessWidget {
Widget build(BuildContext context) {
final servers = c.settings.webdavServers;
return Column(children: [
ListTile(
SettingTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('WebDAV'),
subtitle: Text(tr('NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 '
@ -29,7 +30,7 @@ class WebDavSettings extends StatelessWidget {
for (final s in servers)
Padding(
padding: const EdgeInsets.only(left: 16),
child: ListTile(
child: SettingTile(
dense: true,
leading: const Icon(Icons.cloud, color: JjColors.accent),
title: Text(s.label),
@ -100,6 +101,7 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
final ok = Uri.tryParse(url.text.trim())?.hasAuthority ?? false;
return AlertDialog(
scrollable: true,
title: Text(old == null ? tr('WebDAV 서버 추가') : tr('WebDAV 서버 고치기')),
content: SizedBox(
width: 520,

View File

@ -57,16 +57,11 @@ class _WorkStatusPageState extends State<WorkStatusPage> {
@override
Widget build(BuildContext context) => Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
AppTopBar(
nav: const AppNavButtons(),
actions: AppActions(c: widget.c),
middle: Row(children: [
Text(tr('작업 현황'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
AppActions(c: widget.c),
]),
),
Expanded(