From d7efce87a668bb6924f1fa757af1bf8c84019f5d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:52:11 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=97=AC=EB=9F=AC=20=EA=B0=9C=20(=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=8F=B4=EB=8D=94,=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=84=A4=EC=A0=95=20>=20MKV=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0),=20=EC=84=B8=EB=B6=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EC=95=84=EB=9E=98=EC=97=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=8C=93=EA=B8=B0.=20=EC=98=88=EC=A0=84?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=ED=8F=B4=EB=8D=94=EB=8A=94=20'?= =?UTF-8?q?=EC=9D=B4=EB=8F=99'=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 12 ++++++ assets/l10n/ja.json | 12 ++++++ assets/l10n/zh-Hans.json | 12 ++++++ lib/app/app_controller.dart | 7 ++-- lib/app/settings.dart | 34 +++++++++++++++-- lib/ui/home_page.dart | 65 +++++++++++++++++++++----------- lib/ui/settings_page.dart | 74 ++++++++++++++++++++++++++++++++----- test/move_test.dart | 36 +++++++++++------- 8 files changed, 201 insertions(+), 51 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 6628054..a6aa752 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -160,14 +160,17 @@ "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 추가": "Add {0}", "{0}. {1} (대기)": "{0}. {1} (waiting)", + "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed", + "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{0}개": "{0}", "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 선택": "{0} selected", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", + "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", "{0}줄": "{0} lines", @@ -493,6 +496,7 @@ "업데이트 · 설치하고 남은 파일": "Update · install leftovers", "업데이트 실패: {0}": "Update failed: {0}", "없음": "None", + "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", "연도": "Year", @@ -560,6 +564,7 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "This version has no zip for Windows.", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.", "이 언어 지우기": "Remove this language", + "이 이동 버튼 지우기": "Delete this move button", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", "이 파일용": "For this file", "이 폴더 선택": "Choose this folder", @@ -569,11 +574,16 @@ "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이동": "Move", "이동 ({0})": "Move ({0})", + "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "Move buttons (bottom right of the details, stacked upward)", + "이동 버튼 추가": "Add move button", + "이동 버튼의 폴더": "Folder of the move button", "이동 폴더": "Move folder", "이동 폴더 (환경 설정에서 바꿀 수 있음)": "Move folder (can be changed in Settings)", "이동: {0} → {1}{2}": "Moved: {0} → {1}{2}", + "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "Move: choose a folder to create a move button (you can make several in Settings > Make MKV > Move buttons)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "Move: moves this video to the move folder{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "Move: moves the {0} checked videos to the move folder", + "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "No move folder is set (Settings > Make MKV > Move buttons)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이름": "Name", @@ -799,6 +809,8 @@ "폴더: {0}": "Folder: {0}", "폴더가 아닙니다: {0}": "Not a folder: {0}", "폴란드어": "Polish", + "표시 이름": "Display name", + "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder", "표준": "Standard", "푸는 중": "Extracting", "프랑스어": "French", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ba15a0a..f79e270 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -160,14 +160,17 @@ "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 추가": "{0} を追加", "{0}. {1} (대기)": "{0}. {1}(待機)", + "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", "{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした", + "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}개": "{0}個", "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 선택": "{0}個選択", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", + "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", "{0}줄": "{0}行", @@ -493,6 +496,7 @@ "업데이트 · 설치하고 남은 파일": "更新・インストールの残り", "업데이트 실패: {0}": "更新失敗: {0}", "없음": "なし", + "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "연도": "年", @@ -560,6 +564,7 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。", "이 언어 지우기": "この言語を削除", + "이 이동 버튼 지우기": "この移動ボタンを削除", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일용": "このファイル用", "이 폴더 선택": "このフォルダーを選択", @@ -569,11 +574,16 @@ "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이동": "移動", "이동 ({0})": "移動({0})", + "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移動ボタン(詳細の右下、上に 1つずつ)", + "이동 버튼 추가": "移動ボタンを追加", + "이동 버튼의 폴더": "移動ボタンのフォルダー", "이동 폴더": "移動フォルダー", "이동 폴더 (환경 설정에서 바꿀 수 있음)": "移動フォルダー(環境設定で変更できます)", "이동: {0} → {1}{2}": "移動: {0} → {1}{2}", + "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移動: 移動先フォルダーを選んで移動ボタンを作ります(環境設定 > MKV 作成 > 移動ボタンで複数作れます)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移動: この動画を移動フォルダーに移します{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移動: チェックした動画 {0}個を移動フォルダーに移します", + "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "移動先フォルダーが決まっていません(環境設定 > MKV 作成 > 移動ボタン)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이름": "名前", @@ -799,6 +809,8 @@ "폴더: {0}": "フォルダー: {0}", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", "폴란드어": "ポーランド語", + "표시 이름": "表示名", + "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します", "표준": "標準", "푸는 중": "展開中", "프랑스어": "フランス語", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 8c35577..c75649d 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -160,14 +160,17 @@ "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 추가": "添加 {0}", "{0}. {1} (대기)": "{0}. {1}(等待)", + "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动", + "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}개": "{0} 个", "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 선택": "已选 {0} 个", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", + "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", "{0}줄": "{0} 行", @@ -493,6 +496,7 @@ "업데이트 · 설치하고 남은 파일": "更新 · 安装残留", "업데이트 실패: {0}": "更新失败: {0}", "없음": "无", + "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "연도": "年份", @@ -560,6 +564,7 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。", "이 언어 지우기": "删除此语言", + "이 이동 버튼 지우기": "删除此移动按钮", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일용": "适用于此文件", "이 폴더 선택": "选择此文件夹", @@ -569,11 +574,16 @@ "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이동": "移动", "이동 ({0})": "移动({0})", + "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移动按钮(详细信息右下角,向上依次排列)", + "이동 버튼 추가": "添加移动按钮", + "이동 버튼의 폴더": "移动按钮的文件夹", "이동 폴더": "移动目标文件夹", "이동 폴더 (환경 설정에서 바꿀 수 있음)": "移动目标文件夹(可在设置中更改)", "이동: {0} → {1}{2}": "已移动: {0} → {1}{2}", + "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移动: 选择目标文件夹以创建移动按钮(可在 设置 > 制作 MKV > 移动按钮 中创建多个)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移动: 将此视频移到移动目标文件夹{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移动: 将勾选的 {0} 个视频移到移动目标文件夹", + "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "尚未设置移动目标文件夹(设置 > 制作 MKV > 移动按钮)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이름": "名称", @@ -799,6 +809,8 @@ "폴더: {0}": "文件夹: {0}", "폴더가 아닙니다: {0}": "不是文件夹: {0}", "폴란드어": "波兰语", + "표시 이름": "显示名称", + "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹", "표준": "标准", "푸는 중": "正在解压", "프랑스어": "法语", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index b2c5bc0..c2b2618 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -425,12 +425,11 @@ class AppController extends ChangeNotifier { return n; } - /// 동영상을 이동 폴더 (환경 설정) 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 과 + /// 동영상을 [dest] (환경 설정의 이동 버튼 폴더) 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 과 /// 만든 결과물 (jj_mkv 의 a.mkv · a_AI.srt · a_ko.srt …) 도 함께 (결과물은 이동 폴더의 jj_mkv 로). /// 옮긴 동영상은 목록에서 뺀다. 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들) - Future<(int, List)> moveVideos(List targets) async { - final dest = settings.moveTargetDir; - if (dest == null || dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)')]); + Future<(int, List)> moveVideos(List targets, String dest) async { + if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]); var moved = 0; final errors = []; try { diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 9b0a84b..92fb7e8 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -8,13 +8,34 @@ import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; +/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 +class MoveTarget { + final String name; + final String dir; + const MoveTarget(this.name, this.dir); + + MoveTarget copyWith({String? name, String? dir}) => MoveTarget(name ?? this.name, dir ?? this.dir); + + Map toJson() => {'name': name, 'dir': dir}; + + factory MoveTarget.fromJson(Map j) => + MoveTarget((j['name'] as String?)?.trim().isNotEmpty == true ? j['name'] as String : '이동', j['dir'] as String); + + @override + bool operator ==(Object other) => other is MoveTarget && other.name == name && other.dir == dir; + + @override + int get hashCode => Object.hash(name, dir); +} + /// 환경 설정 (settings.json 에 저장) class AppSettings { /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv String? mkvOutputRoot; - /// MKV 목록의 [이동] · 세 번 누르기로 동영상을 옮길 폴더. null 이면 처음 옮길 때 고른다 - String? moveTargetDir; + /// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로. + /// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다. + List moveTargets = []; /// 웹 브라우저에서 YouTube 광고 자동 건너뛰기 (건너뛰기 버튼 누르기 · 건너뛸 수 없는 광고는 빨리 감기 · 소리 끄기) bool youtubeAdSkip = true; @@ -130,7 +151,7 @@ class AppSettings { Map toJson() => { 'mkvOutputRoot': mkvOutputRoot, - 'moveTargetDir': moveTargetDir, + 'moveTargets': [for (final t in moveTargets) t.toJson()], 'youtubeAdSkip': youtubeAdSkip, 'youtubeAdHide': youtubeAdHide, 'downloadRoot': downloadRoot, @@ -193,7 +214,12 @@ class AppSettings { final e = (j['encode'] as Map?) ?? const {}; return AppSettings() ..mkvOutputRoot = j['mkvOutputRoot'] as String? - ..moveTargetDir = j['moveTargetDir'] as String? + ..moveTargets = [ + for (final x in (j['moveTargets'] as List?) ?? const []) + if (x is Map && x['dir'] is String) MoveTarget.fromJson(x), + // 예전 판의 이동 폴더 하나 → "이동" 버튼으로 + if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String), + ] ..youtubeAdSkip = j['youtubeAdSkip'] as bool? ?? true ..youtubeAdHide = j['youtubeAdHide'] as bool? ?? true ..downloadRoot = j['downloadRoot'] as String? diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 7b3ee3d..d68d4a4 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -14,6 +14,9 @@ import '../core/output_paths.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'downloads_page.dart'; +import 'package:path/path.dart' as p; + +import '../app/settings.dart' show MoveTarget; import 'folder_picker.dart'; import 'player_page.dart'; import 'subtitle_editor_page.dart'; @@ -707,25 +710,41 @@ class _VideoDetail extends StatelessWidget { // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) final locked = v.status == JobStatus.running; - // 오른쪽 아래 [이동]: 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상 - final moveTargets = c.checked.isEmpty ? [v] : c.batchTargets; + // 오른쪽 아래 이동 버튼들 (환경 설정 > MKV 만들기 > 이동 버튼): 표시 이름으로, 위로 하나씩. + // 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상을 그 버튼의 폴더로 옮긴다. + final moving = c.checked.isEmpty ? [v] : c.batchTargets; + final canMove = moving.any((x) => x.status != JobStatus.running); + final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; + final buttons = c.settings.moveTargets; + Widget button(MoveTarget? t) => Tooltip( + message: t == null + ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') + : c.checked.isEmpty + ? trf('{0}: 이 동영상을 {1} 로 옮깁니다', [t.name, t.dir]) + : trf('{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다', [t.name, c.checked.length, t.dir]), + child: FloatingActionButton.extended( + heroTag: null, + onPressed: canMove ? () => moveToTarget(context, c, moving, target: t) : null, + icon: const Icon(Icons.drive_file_move_outline), + label: Text('${t?.name ?? tr('이동')}$count'), + ), + ); return Stack(children: [ _list(context, info, video, audioCount, locked), Positioned( right: 16, bottom: 16, - child: Tooltip( - message: c.checked.isEmpty - ? trf('이동: 이 동영상을 이동 폴더로 옮깁니다{0}', [c.settings.moveTargetDir == null ? '' : ' (${c.settings.moveTargetDir})']) - : trf('이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다', [c.checked.length]), - child: FloatingActionButton.extended( - heroTag: null, - onPressed: moveTargets.any((x) => x.status != JobStatus.running) - ? () => moveToTarget(context, c, moveTargets) - : null, - icon: const Icon(Icons.drive_file_move_outline), - label: Text(c.checked.isEmpty ? tr('이동') : trf('이동 ({0})', [c.checked.length])), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (buttons.isEmpty) button(null), + // 첫 번째 버튼이 맨 아래 (예전 [이동] 자리), 다음 버튼은 그 위로 + for (var i = buttons.length - 1; i >= 0; i--) ...[ + button(buttons[i]), + if (i > 0) const SizedBox(height: 10), + ], + ], ), ), ]); @@ -1044,19 +1063,23 @@ class _LogStrip extends StatelessWidget { ); } -/// 동영상을 이동 폴더로 옮긴다 (MKV 목록의 [이동] · 세 번 누르기). 이동 폴더를 아직 안 정했으면 먼저 고른다. -Future moveToTarget(BuildContext context, AppController c, List targets) async { - if (targets.isEmpty) return; +/// 동영상을 이동 버튼의 폴더로 옮긴다 (세부 정보의 이동 버튼 · 세 번 누르기). [target] 이 없으면 첫 번째 버튼, +/// 이동 버튼이 하나도 없으면 폴더를 골라 하나 만든다 (표시 이름은 폴더 이름). +Future moveToTarget(BuildContext context, AppController c, List videos, {MoveTarget? target}) async { + if (videos.isEmpty) return; final messenger = ScaffoldMessenger.maybeOf(context); - if (c.settings.moveTargetDir == null) { + var t = target ?? c.settings.moveTargets.firstOrNull; + if (t == null) { final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)')); if (d == null) return; - await c.updateSettings((x) => x.moveTargetDir = d); + t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d); + final add = t; + await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, add]); } - final (moved, errors) = await c.moveVideos(targets); + final (moved, errors) = await c.moveVideos(videos, t.dir); messenger?.showSnackBar(SnackBar( content: Text([ - if (moved > 0) trf('{0}개를 옮겼습니다 → {1}', [moved, c.settings.moveTargetDir]), + if (moved > 0) trf('{0}개를 옮겼습니다 → {1} ({2})', [moved, t.name, t.dir]), ...errors, ].join('\n')), )); diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index a96b3e5..c1687f4 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../core/download_detect.dart'; @@ -373,15 +374,31 @@ class _SettingsPageState extends State { }, onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), ), - _folderTile( - title: tr('이동 폴더'), - value: s.moveTargetDir, - defaultText: tr('정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다'), - onPick: () async { - final d = await _pickDir(tr('이동 폴더'), s.moveTargetDir); - if (d != null) await c.updateSettings((x) => x.moveTargetDir = d); - }, - onReset: () => c.updateSettings((x) => x.moveTargetDir = null), + _subTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')), + for (var i = 0; i < s.moveTargets.length; i++) _moveTargetTile(i, s.moveTargets[i]), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row(children: [ + OutlinedButton.icon( + onPressed: () async { + final d = await _pickDir(tr('이동 버튼의 폴더')); + if (d == null) return; + final name = p.basename(d).isEmpty ? tr('이동') : p.basename(d); + await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, MoveTarget(name, d)]); + }, + icon: const Icon(Icons.add, size: 18), + label: Text(tr('이동 버튼 추가')), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + s.moveTargets.isEmpty + ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') + : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ), + ]), ), ]), _group('subtitle', Icons.subtitles_outlined, tr('자막 (AI · 인터넷)'), [ @@ -709,6 +726,45 @@ class _SettingsPageState extends State { ); } + /// 이동 버튼 하나: 표시 이름 (바로 고침) · 폴더 · 순서 올리기 · 폴더 바꾸기 · 지우기 + Widget _moveTargetTile(int i, MoveTarget t) { + final s = c.settings; + void update(List Function(List) f) => + c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); + return ListTile( + key: ValueKey('move_${i}_${t.dir}'), + leading: const Icon(Icons.drive_file_move_outline, color: JjColors.accent), + title: TextFormField( + initialValue: t.name, + decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')), + onChanged: (v) => update((l) => l..[i] = t.copyWith(name: v.trim().isEmpty ? tr('이동') : v.trim())), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(t.dir, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('위로'), + icon: const Icon(Icons.arrow_upward, size: 18), + onPressed: i == 0 ? null : () => update((l) => l..insert(i - 1, l.removeAt(i))), + ), + OutlinedButton( + onPressed: () async { + final d = await _pickDir(tr('이동 버튼의 폴더'), t.dir); + if (d != null) update((l) => l..[i] = t.copyWith(dir: d)); + }, + child: Text(tr('폴더 선택')), + ), + IconButton( + tooltip: tr('이 이동 버튼 지우기'), + icon: const Icon(Icons.delete_outline), + onPressed: s.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null, + ), + ]), + ); + } + /// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤) final _groupKeys = {}; diff --git a/test/move_test.dart b/test/move_test.dart index 4737233..f0d7241 100644 --- a/test/move_test.dart +++ b/test/move_test.dart @@ -27,7 +27,6 @@ void main() { test('이동: 동영상 + 옆의 같은 이름 자막을 옮기고 목록에서 뺀다. 같은 이름이 있으면 (2)', () async { final c = _controller(); final dest = p.join(dir.path, 'done'); - c.settings.moveTargetDir = dest; final a = VideoItem(make('a.mp4').path) ..subtitles.add(SubtitleEntry.external(path: make('a.ko.srt').path, language: undetermined)) ..subtitles.add(SubtitleEntry.external(path: make('other.srt').path, language: undetermined)); @@ -43,7 +42,7 @@ void main() { c.videos.addAll([a, b, busy]); c.selected = a; - final (moved, errors) = await c.moveVideos([a, b, busy]); + final (moved, errors) = await c.moveVideos([a, b, busy], dest); expect(moved, 2); expect(errors.single, contains('작업 중')); expect(File(p.join(dest, 'a.mp4')).existsSync(), isTrue); @@ -80,25 +79,28 @@ void main() { final c = _controller(); final a = VideoItem(make('a.mp4').path); c.videos.add(a); - final (moved, errors) = await c.moveVideos([a]); + final (moved, errors) = await c.moveVideos([a], ''); expect(moved, 0); expect(errors.single, contains('이동할 폴더')); expect(File(a.path).existsSync(), isTrue); }); - test('이동 폴더 설정 저장', () { - final s = AppSettings()..moveTargetDir = r'D:\done'; - expect(AppSettings.fromJson(s.toJson()).moveTargetDir, r'D:\done'); - expect(AppSettings().moveTargetDir, isNull); + test('이동 버튼 설정 저장 (표시 이름 · 폴더 · 순서), 예전 이동 폴더 하나는 "이동" 버튼으로', () { + final s = AppSettings() + ..moveTargets = const [MoveTarget('완료', r'D:\done'), MoveTarget('보관', r'E:\archive')]; + expect(AppSettings.fromJson(s.toJson()).moveTargets, s.moveTargets); + expect(AppSettings().moveTargets, isEmpty); + expect(AppSettings.fromJson({'moveTargetDir': r'D:\old'}).moveTargets, const [MoveTarget('이동', r'D:\old')]); }); - testWidgets('MKV 목록: 세 번 누르면 이동 · 세부 정보의 [이동] 은 체크한 것 (없으면 보고 있는 것)', (tester) async { + testWidgets('MKV 목록: 세 번 누르면 첫 번째 이동 버튼으로 · 세부 정보의 이동 버튼 (표시 이름) 은 그 폴더로', (tester) async { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = _controller(); final dest = p.join(dir.path, 'done'); - c.settings.moveTargetDir = dest; + final archive = p.join(dir.path, 'archive'); + c.settings.moveTargets = [MoveTarget('완료함', dest), MoveTarget('보관함', archive)]; final a = VideoItem(make('a.mp4').path); final b = VideoItem(make('b.mp4').path); final d = VideoItem(make('d.mp4').path); @@ -124,13 +126,21 @@ void main() { expect(File(p.join(dest, 'b.mp4')).existsSync(), isTrue); expect(c.videos.map((v) => v.fileName), ['a.mp4', 'd.mp4']); - // 체크 없음: [이동] → 보고 있는 동영상 (a) + // 세부 정보 오른쪽 아래: 표시 이름 버튼 두 개 (첫 번째가 아래, 두 번째가 그 위) c.select(a); await tester.pump(); - expect(find.text('이동'), findsOneWidget); - await tester.tap(find.text('이동')); + expect(find.text('완료함'), findsOneWidget); + expect(find.text('보관함'), findsOneWidget); + expect(find.text('이동'), findsNothing); + expect(tester.getCenter(find.text('보관함')).dy, lessThan(tester.getCenter(find.text('완료함')).dy)); + // 체크 없음: [보관함] → 보고 있는 동영상 (a) 를 보관 폴더로 + await tester.tap(find.text('보관함')); await settleIo(); - expect(File(p.join(dest, 'a.mp4')).existsSync(), isTrue); + expect(File(p.join(archive, 'a.mp4')).existsSync(), isTrue); expect(c.videos.map((v) => v.fileName), ['d.mp4']); + // 체크하면 버튼에 개수 + c.toggleChecked(d); + await tester.pump(); + expect(find.text('완료함 (1)'), findsOneWidget); }); }