좁은 화면 (접은 폴드 · 휴대폰 세로) 배치 정리
- AppTopBar: 폭 720 미만이면 위쪽 막대를 두 줄로 (위: 화면 이동 · 화면 크기 · 설정 · 종료, 아래: 화면의 도구). 모든 화면 (MKV · 브라우저 · 탐색기 · Rsync · 모니터링 · 다운로드 · 설정 · 플레이어 · 자막 편집 · 작업 현황 · 즐겨찾기) 에 적용. 접은 폴드에서 설정 · 종료 버튼이 화면 밖으로 밀려 누를 수 없던 것. - MKV 화면: 좁으면 목록 위 · 자세히 보기 아래, 변환 설정 줄은 옆으로 밀어 보기, 자막 버튼은 줄바꿈. - 대화 상자 37개: 내용이 화면보다 길면 스크롤 (가로 폴드에서 버튼이 내용과 겹치던 것). - SettingTile: 좁은 화면에서 설정 줄의 고르기 상자 · 입력 칸을 설명 아래로. - 다운로드 도구 줄 옆으로 밀어 보기, 빈 목록 안내 글 여백. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
44ae09a31f
commit
2553107aa5
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 의 시작 버튼으로')),
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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('닫기')))],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue