diff --git a/assets/l10n/en.json b/assets/l10n/en.json
index 2101b64..6953c84 100644
--- a/assets/l10n/en.json
+++ b/assets/l10n/en.json
@@ -125,6 +125,7 @@
"Whisper small (정확)": "Whisper small (accurate)",
"Windows 기본 브라우저": "Windows default browser",
"Windows 기본 프로그램": "Windows default program",
+ "Windows 탐색기": "Windows Explorer",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube is asking for a robot check. In Settings > Downloads > YouTube cookies, choose a browser (Firefox recommended) or a cookies.txt, then download again.",
"YouTube 광고 배너 숨기기": "Hide YouTube ad banners",
@@ -165,6 +166,7 @@
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
"{0} 추가": "Add {0}",
+ "{0} 파일": "{0} file",
"{0}. {1} (대기)": "{0}. {1} (waiting)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}",
"{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder",
@@ -240,6 +242,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
"기본값": "Default",
"기타 즐겨찾기": "Other bookmarks",
+ "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
"길이 {0}": "Length {0}",
"끄기": "Off",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "When off, starts right away with the last settings: source {0}, languages {1}, {2}",
@@ -284,6 +287,7 @@
"노랑": "Yellow",
"노르웨이어": "Norwegian",
"누르고 있으면 조정 전": "Hold to see before",
+ "누르기": "Tap",
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "As soon as they finish, put them in the Make MKV video list and remove them from the download list (same as the checkbox on the download screen)",
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "When finished, automatically add to the Make MKV video list and remove from this download list (the downloaded file stays)",
"다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser",
@@ -450,6 +454,7 @@
"비밀번호 (선택)": "Password (optional)",
"비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
+ "비어 있음": "Empty",
"사용 안 함": "Off",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears",
"사용자가 취소했습니다.": "Cancelled by the user.",
@@ -499,6 +504,7 @@
"세로 고정": "Portrait lock",
"수정": "Edit",
"수정 · 이동": "Edit · move",
+ "수정한 날짜": "Date modified",
"숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "Even when hidden, you can change the pane layout · buttons from the ⋮ menu at the top.",
"숨김": "Hidden",
"숨은 항목 표시": "Show hidden items",
@@ -506,6 +512,7 @@
"숨은 항목을 보입니다.": "Hidden items are shown.",
"쉬는 중": "Idle",
"스웨덴어": "Swedish",
+ "스타일": "Style",
"스페인어": "Spanish",
"시간 형식 오류 {0}곳": "{0} time format errors",
"시간순 정렬": "Sort by time",
@@ -531,6 +538,7 @@
"아래에 줄 추가": "Add line below",
"아이디 (선택)": "Username (optional)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).",
+ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])",
"아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).",
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
@@ -585,6 +593,7 @@
"왼쪽": "Left",
"왼쪽 90°": "Left 90°",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "Reorder with the handle on the left · drag the icon onto a folder on the left or in the list to move it there · right-click menu",
+ "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "Folders on the left, the files of the folder chosen on the left on the right (like Windows Explorer).",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
"우르두어": "Urdu",
"우크라이나어": "Ukrainian",
@@ -607,6 +616,7 @@
"위 폴더": "Parent folder",
"위로": "Up",
"위아래": "Top and bottom",
+ "유형": "Type",
"음성": "Audio",
"음성 {0}개": "{0} audio",
"음성 선택": "Choose audio",
@@ -871,12 +881,15 @@
"트랙 {0}": "Track {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
"트레이 정리": "Clean up tray",
+ "파일": "File",
"파일 관리자에서 보기": "Show in file manager",
"파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "When JJ_MKVMaker is chosen from \"Open with\" · Share in a file app",
"파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "The file location is unknown, so it will only be played. To make an MKV, choose it with \"Add videos\".",
"파일 이름": "File name",
"파일 탐색기": "File explorer",
"파일 탐색기 (지금 여기)": "File explorer (you are here)",
+ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)",
+ "파일 폴더": "File folder",
"파일 확인 중…": "Checking files…",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"팝업 끄기": "Close pop-up",
@@ -891,6 +904,7 @@
"포르투갈어": "Portuguese",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)",
"폴더": "Folder",
+ "폴더 + 파일 목록": "Folders + file list",
"폴더 만들기": "New folder",
"폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)",
"폴더 선택": "Choose folder",
@@ -926,6 +940,8 @@
"필수 프로그램 점검": "Check required programs",
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
"하위 폴더가 없습니다": "No subfolders",
+ "한 번: 바로 실행": "Single: open right away",
+ "한 번: 선택 · 두 번: 실행 (기본)": "Single: select · Double: open (default)",
"한 창": "One pane",
"한국어": "Korean",
"한글": "Hangul",
@@ -954,6 +970,7 @@
"화질": "Quality",
"확인": "OK",
"확인 · 압축 푸는 중…": "Verifying · extracting…",
+ "확장자": "Ext",
"확장자별 재생 프로그램": "Player per file extension",
"환경 설정": "Settings",
"회전": "Rotation",
diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json
index 17f1ca8..160f5c4 100644
--- a/assets/l10n/ja.json
+++ b/assets/l10n/ja.json
@@ -125,6 +125,7 @@
"Whisper small (정확)": "Whisper small(高精度)",
"Windows 기본 브라우저": "Windows の既定のブラウザー",
"Windows 기본 프로그램": "Windows の既定のプログラム",
+ "Windows 탐색기": "Windows エクスプローラー",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube がロボット確認を求めています。環境設定 > ダウンロード > YouTube クッキー でブラウザー(Firefox 推奨)または cookies.txt を指定してから、もう一度ダウンロードしてください。",
"YouTube 광고 배너 숨기기": "YouTube の広告バナーを隠す",
@@ -165,6 +166,7 @@
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 추가": "{0} を追加",
+ "{0} 파일": "{0} ファイル",
"{0}. {1} (대기)": "{0}. {1}(待機)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します",
"{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります",
@@ -240,6 +242,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
"기본값": "既定値",
"기타 즐겨찾기": "その他のブックマーク",
+ "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
"길이 {0}": "長さ {0}",
"끄기": "オフ",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}",
@@ -284,6 +287,7 @@
"노랑": "黄色",
"노르웨이어": "ノルウェー語",
"누르고 있으면 조정 전": "押している間は調整前",
+ "누르기": "タップ",
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "ダウンロードが終わり次第 MKV 作成の動画リストに入れ、ダウンロードリストからは外します(ダウンロード画面のチェックボックスと同じ)",
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "ダウンロードが終わると MKV 作成の動画リストに自動で入れ、このダウンロードリストからは外します(ダウンロードしたファイルはそのまま)",
"다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む",
@@ -450,6 +454,7 @@
"비밀번호 (선택)": "パスワード(任意)",
"비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
+ "비어 있음": "空",
"사용 안 함": "使用しない",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください",
"사용자가 취소했습니다.": "ユーザーがキャンセルしました。",
@@ -499,6 +504,7 @@
"세로 고정": "縦向き固定",
"수정": "編集",
"수정 · 이동": "編集・移動",
+ "수정한 날짜": "更新日時",
"숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "非表示にしても、上の ⋮ メニューからペインの配置 · ボタン構成を変えられます。",
"숨김": "非表示",
"숨은 항목 표시": "隠し項目を表示",
@@ -506,6 +512,7 @@
"숨은 항목을 보입니다.": "隠し項目を表示します。",
"쉬는 중": "休止中",
"스웨덴어": "スウェーデン語",
+ "스타일": "スタイル",
"스페인어": "スペイン語",
"시간 형식 오류 {0}곳": "時間形式エラー {0}か所",
"시간순 정렬": "時間順に並べ替え",
@@ -531,6 +538,7 @@
"아래에 줄 추가": "下に行を追加",
"아이디 (선택)": "ユーザー名(任意)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
+ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
"아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。",
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
@@ -585,6 +593,7 @@
"왼쪽": "左",
"왼쪽 90°": "左 90°",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー",
+ "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左にフォルダー、右に左で選んだフォルダーのファイルを表示します (Windows エクスプローラーのように)。",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
"우르두어": "ウルドゥー語",
"우크라이나어": "ウクライナ語",
@@ -607,6 +616,7 @@
"위 폴더": "上のフォルダー",
"위로": "上へ",
"위아래": "上下",
+ "유형": "種類",
"음성": "音声",
"음성 {0}개": "音声 {0}個",
"음성 선택": "音声を選択",
@@ -871,12 +881,15 @@
"트랙 {0}": "トラック {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
"트레이 정리": "トレイの後片付け",
+ "파일": "ファイル",
"파일 관리자에서 보기": "ファイルマネージャーで表示",
"파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "ファイルアプリの「アプリで開く」・共有で JJ_MKVMaker を選んだとき",
"파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "ファイルの場所がわからないため再生のみ行います。MKV を作成するには「動画を追加」で選んでください。",
"파일 이름": "ファイル名",
"파일 탐색기": "ファイルエクスプローラー",
"파일 탐색기 (지금 여기)": "ファイルエクスプローラー (現在地)",
+ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)",
+ "파일 폴더": "ファイル フォルダー",
"파일 확인 중…": "ファイルを確認中…",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"팝업 끄기": "ポップアップを閉じる",
@@ -891,6 +904,7 @@
"포르투갈어": "ポルトガル語",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)",
"폴더": "フォルダー",
+ "폴더 + 파일 목록": "フォルダー + ファイル一覧",
"폴더 만들기": "フォルダーを作成",
"폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)",
"폴더 선택": "フォルダーを選択",
@@ -926,6 +940,8 @@
"필수 프로그램 점검": "必須プログラムの点検",
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
"하위 폴더가 없습니다": "サブフォルダーがありません",
+ "한 번: 바로 실행": "1 回: すぐに開く",
+ "한 번: 선택 · 두 번: 실행 (기본)": "1 回: 選択 · 2 回: 開く (既定)",
"한 창": "1 ペイン",
"한국어": "韓国語",
"한글": "ハングル",
@@ -954,6 +970,7 @@
"화질": "画質",
"확인": "OK",
"확인 · 압축 푸는 중…": "確認・展開中…",
+ "확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
"환경 설정": "環境設定",
"회전": "回転",
diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json
index 14d32f4..01d502b 100644
--- a/assets/l10n/zh-Hans.json
+++ b/assets/l10n/zh-Hans.json
@@ -125,6 +125,7 @@
"Whisper small (정확)": "Whisper small(准确)",
"Windows 기본 브라우저": "Windows 默认浏览器",
"Windows 기본 프로그램": "Windows 默认程序",
+ "Windows 탐색기": "Windows 资源管理器",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube 要求进行机器人验证。请在 设置 > 下载 > YouTube Cookie 中指定浏览器(推荐 Firefox)或 cookies.txt 后重新下载。",
"YouTube 광고 배너 숨기기": "隐藏 YouTube 广告横幅",
@@ -165,6 +166,7 @@
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 추가": "添加 {0}",
+ "{0} 파일": "{0} 文件",
"{0}. {1} (대기)": "{0}. {1}(等待)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}",
"{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中",
@@ -240,6 +242,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
"기본값": "默认值",
"기타 즐겨찾기": "其他书签",
+ "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
"길이 {0}": "时长 {0}",
"끄기": "关闭",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}",
@@ -284,6 +287,7 @@
"노랑": "黄色",
"노르웨이어": "挪威语",
"누르고 있으면 조정 전": "按住查看调整前",
+ "누르기": "点按",
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "下载完成后立即放入制作 MKV 的视频列表,并从下载列表移除(与下载界面的复选框相同)",
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "下载完成后自动加入制作 MKV 的视频列表,并从此下载列表移除(已下载的文件保留)",
"다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签",
@@ -450,6 +454,7 @@
"비밀번호 (선택)": "密码(可选)",
"비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
+ "비어 있음": "空",
"사용 안 함": "不使用",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置",
"사용자가 취소했습니다.": "用户已取消。",
@@ -499,6 +504,7 @@
"세로 고정": "锁定竖屏",
"수정": "编辑",
"수정 · 이동": "编辑 · 移动",
+ "수정한 날짜": "修改日期",
"숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "即使隐藏,也可以在顶部 ⋮ 菜单中更改窗格布局 · 按钮配置。",
"숨김": "隐藏",
"숨은 항목 표시": "显示隐藏项目",
@@ -506,6 +512,7 @@
"숨은 항목을 보입니다.": "已显示隐藏项目。",
"쉬는 중": "空闲",
"스웨덴어": "瑞典语",
+ "스타일": "样式",
"스페인어": "西班牙语",
"시간 형식 오류 {0}곳": "{0} 处时间格式错误",
"시간순 정렬": "按时间排序",
@@ -531,6 +538,7 @@
"아래에 줄 추가": "在下方添加一行",
"아이디 (선택)": "用户名(可选)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
+ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
"아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。",
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
@@ -585,6 +593,7 @@
"왼쪽": "左侧",
"왼쪽 90°": "向左 90°",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单",
+ "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左侧显示文件夹,右侧显示左侧所选文件夹中的文件 (类似 Windows 资源管理器)。",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
"우르두어": "乌尔都语",
"우크라이나어": "乌克兰语",
@@ -607,6 +616,7 @@
"위 폴더": "上级文件夹",
"위로": "向上",
"위아래": "上下",
+ "유형": "类型",
"음성": "音频",
"음성 {0}개": "音频 {0} 个",
"음성 선택": "选择音轨",
@@ -871,12 +881,15 @@
"트랙 {0}": "轨道 {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
"트레이 정리": "清理托盘",
+ "파일": "文件",
"파일 관리자에서 보기": "在文件管理器中显示",
"파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "在文件应用的“打开方式” · 分享中选择 JJ_MKVMaker 时",
"파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "无法确定文件位置,因此仅播放。要制作 MKV,请用“添加视频”选择。",
"파일 이름": "文件名",
"파일 탐색기": "文件浏览器",
"파일 탐색기 (지금 여기)": "文件浏览器 (当前位置)",
+ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)",
+ "파일 폴더": "文件夹",
"파일 확인 중…": "正在检查文件…",
"파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}",
"팝업 끄기": "关闭弹出窗口",
@@ -891,6 +904,7 @@
"포르투갈어": "葡萄牙语",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)",
"폴더": "文件夹",
+ "폴더 + 파일 목록": "文件夹 + 文件列表",
"폴더 만들기": "新建文件夹",
"폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)",
"폴더 선택": "选择文件夹",
@@ -926,6 +940,8 @@
"필수 프로그램 점검": "检查必需程序",
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
"하위 폴더가 없습니다": "没有子文件夹",
+ "한 번: 바로 실행": "单击: 立即打开",
+ "한 번: 선택 · 두 번: 실행 (기본)": "单击: 选择 · 双击: 打开 (默认)",
"한 창": "单窗格",
"한국어": "韩语",
"한글": "韩文",
@@ -954,6 +970,7 @@
"화질": "画质",
"확인": "确定",
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
+ "확장자": "扩展名",
"확장자별 재생 프로그램": "按扩展名设置播放程序",
"환경 설정": "设置",
"회전": "旋转",
diff --git a/integration_test/explorer_shot_test.dart b/integration_test/explorer_shot_test.dart
index b6c11e2..0e51b00 100644
--- a/integration_test/explorer_shot_test.dart
+++ b/integration_test/explorer_shot_test.dart
@@ -21,11 +21,16 @@ void main() {
final c = AppController(PlatformServices.create());
c.settings.explorerPaths = [env['JJ_SHOT_LEFT'] ?? Directory.current.path, env['JJ_SHOT_RIGHT'] ?? Directory.current.path];
final key = GlobalKey();
- for (final (name, size, orient) in [
- ('wide', const Size(1600, 900), 'auto'),
- ('tall', const Size(900, 1400), 'auto'),
+ for (final (name, size, layout, style) in [
+ ('wide', const Size(1600, 900), 'dual', 'xplore'),
+ ('tall', const Size(900, 1400), 'dual', 'xplore'),
+ ('windows', const Size(1600, 900), 'dual', 'windows'),
+ ('totalcmd', const Size(1600, 900), 'dual', 'totalcmd'),
+ ('split', const Size(1600, 900), 'split', 'windows'),
]) {
- c.settings.explorerOrientation = orient;
+ c.settings
+ ..explorerLayout = layout
+ ..explorerStyle = style;
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
diff --git a/lib/app/settings.dart b/lib/app/settings.dart
index 24eb47f..4e864ac 100644
--- a/lib/app/settings.dart
+++ b/lib/app/settings.dart
@@ -134,9 +134,16 @@ class AppSettings {
bool webJavaScript = true;
// ── 파일 탐색기 (X-plore 참고) ──
- /// 창: 'dual' 두 창 (기본) · 'single' 한 창
+ /// 창: 'dual' 두 창 (기본) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 · 'single' 한 창
String explorerLayout = 'dual';
+ /// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본) / 'open' 한 번 = 바로 열기.
+ /// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
+ String explorerClick = 'select';
+
+ /// 모양: 'xplore' (기본) · 'windows' Windows 탐색기 · 'totalcmd' Total Commander
+ String explorerStyle = 'xplore';
+
/// 두 창 배치: 'auto' 화면 모양 따라 (가로로 넓으면 좌우, 세로로 길면 위아래) · 'side' 좌우 · 'stacked' 위아래
String explorerOrientation = 'auto';
@@ -224,6 +231,8 @@ class AppSettings {
'webTranslate': webTranslate,
'webJavaScript': webJavaScript,
'explorerLayout': explorerLayout,
+ 'explorerClick': explorerClick,
+ 'explorerStyle': explorerStyle,
'explorerOrientation': explorerOrientation,
'explorerToolbar': explorerToolbar,
'explorerButtons': explorerButtons,
@@ -306,7 +315,9 @@ class AppSettings {
..externalBrowser = j['externalBrowser'] as String? ?? 'system'
..webTranslate = j['webTranslate'] as bool? ?? false
..webJavaScript = j['webJavaScript'] as bool? ?? true
- ..explorerLayout = j['explorerLayout'] == 'single' ? 'single' : 'dual'
+ ..explorerLayout = const ['single', 'split'].contains(j['explorerLayout']) ? j['explorerLayout'] as String : 'dual'
+ ..explorerClick = j['explorerClick'] == 'open' ? 'open' : 'select'
+ ..explorerStyle = const ['windows', 'totalcmd'].contains(j['explorerStyle']) ? j['explorerStyle'] as String : 'xplore'
..explorerOrientation = const ['side', 'stacked'].contains(j['explorerOrientation'])
? j['explorerOrientation'] as String
: 'auto'
diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart
new file mode 100644
index 0000000..95392df
--- /dev/null
+++ b/lib/ui/explorer_look.dart
@@ -0,0 +1,138 @@
+import 'package:flutter/material.dart';
+
+import '../core/file_ops.dart';
+import '../core/playlist.dart' show isVideoFile;
+import '../l10n/tr.dart';
+import 'theme.dart';
+
+/// 파일 탐색기 모양 (환경 설정 > 파일 탐색기 > 스타일)
+///
+/// - X-plore: 큰 아이콘 · 동영상 썸네일 · 두 줄 (이름 / 크기 · 날짜 · 해상도 · 길이)
+/// - Windows 탐색기: 작은 컬러 아이콘 · 한 줄 · 열 (이름 · 수정한 날짜 · 유형 · 크기)
+/// - Total Commander: 아주 작은 아이콘 · 촘촘한 한 줄 · 폴더는 [이름] · 열 (이름 · 확장자 · 크기 · 날짜), 크기 칸의 `
`
+enum ExplorerStyle {
+ xplore('X-plore'),
+ windows('Windows 탐색기'),
+ totalcmd('Total Commander');
+
+ final String label;
+ const ExplorerStyle(this.label);
+
+ static ExplorerStyle of(String name) => values.firstWhere((s) => s.name == name, orElse: () => xplore);
+
+ double get rowHeight => switch (this) { xplore => 52, windows => 34, totalcmd => 26 };
+ double get iconSize => switch (this) { xplore => 30, windows => 20, totalcmd => 16 };
+ double get indent => switch (this) { xplore => 16, windows => 14, totalcmd => 12 };
+ double get fontSize => switch (this) { xplore => 14, windows => 13, totalcmd => 12.5 };
+
+ /// 썸네일 · 두 줄 정보 (X-plore 만)
+ bool get rich => this == xplore;
+
+ /// 열 머리 (이름 · 날짜 · 유형 · 크기)
+ bool get columns => this != xplore;
+}
+
+const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic'};
+const _audio = {'mp3', 'm4a', 'flac', 'wav', 'ogg', 'aac', 'opus', 'wma'};
+const _subs = {'srt', 'ass', 'ssa', 'smi', 'sami', 'vtt'};
+const _archives = {'zip', '7z', 'rar', 'tar', 'gz', 'xz', 'bz2'};
+const _texts = {'txt', 'log', 'md', 'json', 'xml', 'ini', 'csv', 'yaml', 'yml'};
+const _docs = {'doc', 'docx', 'hwp', 'hwpx', 'odt', 'rtf'};
+const _sheets = {'xls', 'xlsx', 'ods'};
+const _slides = {'ppt', 'pptx', 'odp'};
+const _programs = {'exe', 'msi', 'bat', 'cmd', 'ps1', 'apk'};
+
+/// 파일 종류별 아이콘 · 색 (스타일마다 다르게)
+(IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) {
+ final x = e.ext;
+ switch (style) {
+ case ExplorerStyle.xplore:
+ if (e.isDir) return (open ? Icons.folder_open : Icons.folder, Colors.amber);
+ return (
+ switch (x) {
+ _ when _subs.contains(x) => Icons.subtitles_outlined,
+ _ when _audio.contains(x) => Icons.audiotrack_outlined,
+ 'pdf' => Icons.picture_as_pdf_outlined,
+ _ when _archives.contains(x) => Icons.folder_zip_outlined,
+ 'apk' => Icons.android,
+ _ when _texts.contains(x) => Icons.description_outlined,
+ _ when _images.contains(x) => Icons.image_outlined,
+ _ when isVideoFile(e.path) => Icons.movie_outlined,
+ _ => Icons.insert_drive_file_outlined,
+ },
+ JjColors.textDim
+ );
+ case ExplorerStyle.windows:
+ // Windows 11 탐색기 느낌: 노란 폴더, 종류마다 색
+ if (e.isDir) return (open ? Icons.folder_open : Icons.folder, const Color(0xFFFFC83D));
+ if (isVideoFile(e.path)) return (Icons.video_file, const Color(0xFF5B9BD5));
+ if (_images.contains(x)) return (Icons.image, const Color(0xFF3FB3A8));
+ if (_audio.contains(x)) return (Icons.audio_file, const Color(0xFFB57EDC));
+ if (_subs.contains(x)) return (Icons.closed_caption, const Color(0xFF9FA8DA));
+ if (x == 'pdf') return (Icons.picture_as_pdf, const Color(0xFFE5534B));
+ if (_archives.contains(x)) return (Icons.folder_zip, const Color(0xFFD8A657));
+ if (_docs.contains(x)) return (Icons.article, const Color(0xFF4F8EDC));
+ if (_sheets.contains(x)) return (Icons.table_chart, const Color(0xFF4CAF7A));
+ if (_slides.contains(x)) return (Icons.slideshow, const Color(0xFFE8804F));
+ if (_programs.contains(x)) return (x == 'apk' ? Icons.android : Icons.terminal, const Color(0xFF8BC34A));
+ if (_texts.contains(x)) return (Icons.description, const Color(0xFFB0BEC5));
+ return (Icons.insert_drive_file, const Color(0xFFB0BEC5));
+ case ExplorerStyle.totalcmd:
+ // Total Commander 느낌: 단순한 작은 아이콘, 폴더는 노랑, 실행 파일은 초록
+ if (e.isDir) return (Icons.folder, const Color(0xFFE8C547));
+ if (_programs.contains(x)) return (Icons.settings_applications, const Color(0xFF7CB342));
+ if (isVideoFile(e.path)) return (Icons.movie, const Color(0xFF64B5F6));
+ if (_images.contains(x)) return (Icons.image, const Color(0xFF64B5F6));
+ if (_archives.contains(x)) return (Icons.inventory_2, const Color(0xFFBCAAA4));
+ return (Icons.description, const Color(0xFFCFD8DC));
+ }
+}
+
+/// Windows 탐색기의 "유형" 칸: "파일 폴더" · "MP4 파일"
+String typeLabel(FileEntry e) => e.isDir
+ ? tr('파일 폴더')
+ : e.ext.isEmpty
+ ? tr('파일')
+ : trf('{0} 파일', [e.ext.toUpperCase()]);
+
+/// Total Commander 의 이름 칸: 폴더는 [이름], 파일은 확장자를 뺀 이름 (확장자는 따로)
+String totalCmdName(FileEntry e) {
+ if (e.isDir) return '[${e.name}]';
+ final n = e.name;
+ final dot = n.lastIndexOf('.');
+ return dot > 0 ? n.substring(0, dot) : n;
+}
+
+/// 열 머리 (Windows 탐색기 · Total Commander)
+class ExplorerColumnsHeader extends StatelessWidget {
+ final ExplorerStyle style;
+ const ExplorerColumnsHeader({super.key, required this.style});
+
+ @override
+ Widget build(BuildContext context) {
+ const st = TextStyle(fontSize: 11.5, color: JjColors.textDim);
+ Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) =>
+ Expanded(flex: flex, child: Text(t, style: st, textAlign: align, maxLines: 1, overflow: TextOverflow.clip));
+ return Container(
+ height: 24,
+ padding: const EdgeInsets.only(left: 10, right: 40),
+ decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: JjColors.border))),
+ child: Row(
+ children: style == ExplorerStyle.windows
+ ? [
+ cell(tr('이름'), 6),
+ cell(tr('수정한 날짜'), 3),
+ cell(tr('유형'), 2),
+ cell(tr('크기'), 2, align: TextAlign.end),
+ ]
+ : [
+ cell(tr('이름'), 6),
+ cell(tr('확장자'), 1),
+ cell(tr('크기'), 2, align: TextAlign.end),
+ const SizedBox(width: 8),
+ cell(tr('날짜'), 3),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart
index 9154da1..b76952b 100644
--- a/lib/ui/explorer_page.dart
+++ b/lib/ui/explorer_page.dart
@@ -13,6 +13,7 @@ import '../core/file_ops.dart';
import '../core/playlist.dart' show isVideoFile;
import '../platform/android/android_storage.dart';
import 'app_actions.dart';
+import 'explorer_look.dart';
import 'player_page.dart';
import 'theme.dart';
import '../l10n/tr.dart';
@@ -97,6 +98,9 @@ class _Pane extends ChangeNotifier {
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
final scroll = ScrollController();
+ /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤
+ final listScroll = ScrollController();
+
_Pane(this.root) : current = root;
void changed() => notifyListeners();
@@ -104,16 +108,20 @@ class _Pane extends ChangeNotifier {
@override
void dispose() {
scroll.dispose();
+ listScroll.dispose();
super.dispose();
}
}
-/// 트리 목록의 한 줄
+/// 트리 · 목록의 한 줄
class _Row {
final FileEntry entry;
final int depth;
final bool isRoot;
- const _Row(this.entry, this.depth, {this.isRoot = false});
+
+ /// 파일 목록 맨 위의 ".." (상위 폴더로)
+ final bool isUp;
+ const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false});
}
class _ExplorerPageState extends State {
@@ -228,21 +236,19 @@ class _ExplorerPageState extends State {
..current = dir
..focused = dir;
pane.changed();
+ if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
_reveal(pane, dir);
if (remember) _remember(pane, dir);
}
- /// 목록 줄 높이
- static const _rowHeight = 52.0;
-
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !pane.scroll.hasClients) return;
- final i = _rows(pane).indexWhere((r) => samePath(r.entry.path, path));
+ final i = _rows(pane, foldersOnly: _split).indexWhere((r) => samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
- final want = (i * _rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent);
+ final want = (i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent);
pane.scroll.jumpTo(want);
});
}
@@ -258,13 +264,24 @@ class _ExplorerPageState extends State {
if (i >= 0) _active = i;
}
- List<_Row> _rows(_Pane pane) {
+ /// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일)
+ ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle);
+
+ /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
+ bool get _split => c.settings.explorerLayout == 'split';
+
+ /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
+ bool get _selectMode => c.settings.explorerClick != 'open';
+
+ /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
+ List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) {
final out = <_Row>[];
final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0));
out.add(_Row(rootEntry, 0, isRoot: true));
void walk(String dir, int depth) {
if (!pane.expanded.contains(dir)) return;
for (final e in pane.cache[dir] ?? const []) {
+ if (foldersOnly && !e.isDir) continue;
out.add(_Row(e, depth));
if (e.isDir) walk(e.path, depth + 1);
}
@@ -276,28 +293,73 @@ class _ExplorerPageState extends State {
// ───────── 누르기 ─────────
- Future _tap(_Pane pane, FileEntry e) async {
+ /// 한 번 누르기.
+ /// - 선택 (기본): 고르기만. 폴더는 그 폴더가 "지금 폴더" (오른쪽 파일 목록 · 새 폴더 등의 기준).
+ /// - 바로 열기: 열기 ([_onOpen]).
+ /// Ctrl 을 누른 채면 표시 (여러 개 고르기). 목록의 ".." 은 늘 상위 폴더로.
+ Future _onTap(_Pane pane, _Row row, {bool list = false}) async {
+ final e = row.entry;
setState(() => _active = _panes.indexOf(pane));
- pane.focused = e.path;
- if (e.isDir) {
- if (pane.expanded.contains(e.path) && samePath(pane.current, e.path)) {
- pane.expanded.remove(e.path); // 지금 폴더를 다시 누르면 접기
- pane.current = p.dirname(e.path).length < pane.root.length ? pane.root : p.dirname(e.path);
- pane.changed();
- } else {
- pane
- ..expanded.add(e.path)
- ..current = e.path;
- await _load(pane, e.path);
- _remember(pane, e.path);
- }
+ if (row.isUp) {
+ await _goTo(pane, e.path);
return;
}
- pane.current = p.dirname(e.path);
+ if (HardwareKeyboard.instance.isControlPressed && !row.isRoot) {
+ setState(() => pane.marked.contains(e.path) ? pane.marked.remove(e.path) : pane.marked.add(e.path));
+ return;
+ }
+ if (!_selectMode) return _onOpen(pane, row, list: list);
+ pane.focused = e.path;
+ if (e.isDir && !list) {
+ pane.current = e.path;
+ pane.changed();
+ await _load(pane, e.path);
+ if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
+ _remember(pane, e.path);
+ } else {
+ if (!list) pane.current = p.dirname(e.path);
+ pane.changed();
+ }
+ }
+
+ /// 열기 (선택 모드는 두 번 누르기, 바로 열기 모드는 한 번 누르기):
+ /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행.
+ Future _onOpen(_Pane pane, _Row row, {bool list = false}) async {
+ final e = row.entry;
+ setState(() => _active = _panes.indexOf(pane));
+ pane.focused = e.path;
+ if (row.isUp || (e.isDir && list)) {
+ await _goTo(pane, e.path);
+ return;
+ }
+ if (e.isDir) {
+ await _toggle(pane, e);
+ return;
+ }
+ if (!list) pane.current = p.dirname(e.path);
pane.changed();
await _open(e.path);
}
+ /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기)
+ Future _toggle(_Pane pane, FileEntry e) async {
+ setState(() => _active = _panes.indexOf(pane));
+ if (pane.expanded.contains(e.path)) {
+ pane.expanded.remove(e.path);
+ // 접은 폴더 안이 지금 폴더였으면 접은 폴더로
+ if (isSameOrInside(pane.current, e.path)) pane.current = e.path;
+ pane.changed();
+ return;
+ }
+ pane
+ ..expanded.add(e.path)
+ ..current = e.path
+ ..focused = e.path;
+ await _load(pane, e.path);
+ if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
+ _remember(pane, e.path);
+ }
+
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future _open(String path) async {
if (isVideoFile(path)) {
@@ -762,15 +824,25 @@ class _ExplorerPageState extends State {
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
- group(tr('창'), layout, [('dual', tr('두 창')), ('single', tr('한 창'))], (v) => layout = v),
- if (layout == 'dual')
+ group(tr('창'), layout, [
+ ('dual', tr('두 창')),
+ ('split', tr('폴더 + 파일 목록')),
+ ('single', tr('한 창')),
+ ], (v) => layout = v),
+ if (layout == 'split')
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 4),
+ child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
+ style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
+ ),
+ if (layout != 'single')
group(tr('두 창 배치'), orient, [
('auto', tr('화면 모양 따라')),
('side', tr('좌우')),
('stacked', tr('위아래')),
], (v) => orient = v),
group(tr('기능 버튼 줄'), bar, [
- ('middle', layout == 'dual' ? tr('두 창 사이') : tr('왼쪽')),
+ ('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')),
('edge', tr('끝 (오른쪽 · 아래)')),
('hidden', tr('숨김')),
], (v) => bar = v),
@@ -918,46 +990,67 @@ class _ExplorerPageState extends State {
]),
);
+ /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
+ String _shape = '';
+
Widget _body() {
final s = c.settings;
+ final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
+ if (_ready && shape != _shape) {
+ if (_shape.isNotEmpty) {
+ for (final pane in _panes) {
+ _reveal(pane, pane.current);
+ }
+ }
+ _shape = shape;
+ }
final dual = s.explorerLayout == 'dual';
+ final split = _split;
return LayoutBuilder(builder: (context, box) {
- final side = !dual || switch (s.explorerOrientation) {
- 'side' => true,
- 'stacked' => false,
- _ => box.maxWidth >= box.maxHeight,
- };
- final bar = s.explorerToolbar == 'hidden' ? null : _Toolbar(
- buttons: ExplorerButton.fromSettings(s.explorerButtons),
- vertical: side,
- onPressed: _button,
- enabled: (b) => switch (b) {
- ExplorerButton.copy || ExplorerButton.move => dual,
- _ => true,
- },
- selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden,
- );
- Widget pane(int i) => Expanded(child: _paneView(i));
+ final side = !(dual || split) ||
+ switch (s.explorerOrientation) {
+ 'side' => true,
+ 'stacked' => false,
+ _ => box.maxWidth >= box.maxHeight,
+ };
+ final bar = s.explorerToolbar == 'hidden'
+ ? null
+ : _Toolbar(
+ buttons: ExplorerButton.fromSettings(s.explorerButtons),
+ vertical: side,
+ onPressed: _button,
+ enabled: (b) => switch (b) {
+ ExplorerButton.copy || ExplorerButton.move => dual,
+ _ => true,
+ },
+ selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden,
+ );
+ final middle = bar != null && s.explorerToolbar == 'middle';
+ final edge = bar != null && s.explorerToolbar == 'edge';
final children = [
if (dual) ...[
- pane(0),
- if (bar != null && s.explorerToolbar == 'middle') bar,
- pane(1),
- if (bar != null && s.explorerToolbar == 'edge') bar,
+ Expanded(child: _paneView(0)),
+ if (middle) bar,
+ Expanded(child: _paneView(1)),
+ if (edge) bar,
+ ] else if (split) ...[
+ // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록
+ Expanded(flex: 2, child: _paneView(0, foldersOnly: true)),
+ if (middle) bar,
+ Expanded(flex: 3, child: _listView(_panes[0])),
+ if (edge) bar,
] else ...[
- if (bar != null && s.explorerToolbar == 'middle') bar,
- pane(0),
- if (bar != null && s.explorerToolbar == 'edge') bar,
+ if (middle) bar,
+ Expanded(child: _paneView(0)),
+ if (edge) bar,
],
];
return side ? Row(children: children) : Column(children: children);
});
}
- Widget _paneView(int i) {
- final pane = _panes[i];
+ Widget _frame(int i, List children) {
final active = i == _active;
- final rows = _rows(pane);
return GestureDetector(
onTapDown: (_) {
if (_active != i) setState(() => _active = i);
@@ -969,32 +1062,77 @@ class _ExplorerPageState extends State {
border: Border.all(color: active ? JjColors.accent : JjColors.border, width: active ? 1.5 : 1),
borderRadius: BorderRadius.circular(6),
),
- child: Column(children: [
- _paneHeader(pane),
- const Divider(height: 1),
- Expanded(
- child: ListView.builder(
- controller: pane.scroll,
- itemCount: rows.length,
- itemExtent: _rowHeight,
- itemBuilder: (_, k) => _rowView(pane, rows[k]),
- ),
- ),
- if (pane.marked.isNotEmpty)
- Container(
- color: JjColors.panel,
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- child: Row(children: [
- Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)),
- const Spacer(),
- TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))),
- ]),
- ),
- ]),
+ child: Column(children: children),
),
);
}
+ Widget _marksBar(_Pane pane) => Container(
+ color: JjColors.panel,
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ child: Row(children: [
+ Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)),
+ const Spacer(),
+ TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))),
+ ]),
+ );
+
+ /// 트리 창 (저장 장치 고르기 + 트리)
+ Widget _paneView(int i, {bool foldersOnly = false}) {
+ final pane = _panes[i];
+ final rows = _rows(pane, foldersOnly: foldersOnly);
+ final look = _look;
+ return _frame(i, [
+ _paneHeader(pane),
+ const Divider(height: 1),
+ if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look),
+ Expanded(
+ child: ListView.builder(
+ controller: pane.scroll,
+ itemCount: rows.length,
+ itemExtent: look.rowHeight,
+ itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly),
+ ),
+ ),
+ if (pane.marked.isNotEmpty && !foldersOnly) _marksBar(pane),
+ ]);
+ }
+
+ /// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
+ Widget _listView(_Pane pane) {
+ final dir = pane.current;
+ final entries = pane.cache[dir] ?? const [];
+ final up = !samePath(dir, pane.root);
+ final rows = [
+ if (up) _Row(FileEntry(p.dirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
+ for (final e in entries) _Row(e, 0),
+ ];
+ final look = _look;
+ return _frame(0, [
+ Container(
+ height: 40,
+ alignment: Alignment.centerLeft,
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ child: Text(dir, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
+ ),
+ const Divider(height: 1),
+ if (look.columns) ExplorerColumnsHeader(style: look),
+ Expanded(
+ child: pane.loading.contains(dir)
+ ? const Center(child: CircularProgressIndicator())
+ : entries.isEmpty && !up
+ ? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim)))
+ : ListView.builder(
+ controller: pane.listScroll,
+ itemCount: rows.length,
+ itemExtent: look.rowHeight,
+ itemBuilder: (_, k) => _rowView(pane, rows[k], list: true),
+ ),
+ ),
+ if (pane.marked.isNotEmpty) _marksBar(pane),
+ ]);
+ }
+
/// 창 위쪽: 저장 장치 고르기
Widget _paneHeader(_Pane pane) => SizedBox(
height: 40,
@@ -1024,106 +1162,166 @@ class _ExplorerPageState extends State {
),
);
- Widget _rowView(_Pane pane, _Row row) {
+ /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
+ Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
final e = row.entry;
- final isCurrent = e.isDir && samePath(pane.current, e.path);
- final isFocused = pane.focused != null && samePath(pane.focused!, e.path);
+ final look = _look;
+ final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
+ final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
final marked = pane.marked.contains(e.path);
- final open = e.isDir && pane.expanded.contains(e.path);
+ final open = e.isDir && !list && pane.expanded.contains(e.path);
final loading = pane.loading.contains(e.path);
- final video = !e.isDir && isVideoFile(e.path);
- final name = row.isRoot ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : e.name;
+ final name = row.isUp
+ ? '..'
+ : row.isRoot
+ ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2)
+ : look == ExplorerStyle.totalcmd
+ ? totalCmdName(e)
+ : e.name;
+ final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim);
+ final nameStyle = TextStyle(
+ fontSize: look.fontSize,
+ fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal,
+ color: isCurrent ? JjColors.accent : null,
+ );
+
+ // 트리의 펼치기 화살표 (따로 누름)
+ Widget chevron() => SizedBox(
+ width: 20,
+ child: e.isDir && !list && !row.isUp
+ ? (loading
+ ? const Center(
+ child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
+ : InkResponse(
+ radius: 16,
+ onTap: () => _toggle(pane, e),
+ child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
+ ))
+ : null,
+ );
+
+ Widget nameCell() => Row(children: [
+ SizedBox(width: list ? 0 : row.depth * look.indent),
+ if (!list) chevron(),
+ SizedBox(
+ width: look.rich ? 56 : look.iconSize + 6,
+ height: look.rowHeight - 8,
+ child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)),
+ ),
+ SizedBox(width: look.rich ? 8 : 6),
+ Expanded(
+ child: look.rich && !e.isDir && !row.isUp
+ ? Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
+ Row(children: [
+ Text('${formatSize(e.size)} · ${_date(e.modified)}', style: dim),
+ if (isVideoFile(e.path)) ...[
+ const SizedBox(width: 8),
+ Flexible(child: _MetaText(c: c, path: e.path)),
+ ],
+ ]),
+ ],
+ )
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
+ if (look.rich && e.isDir && pane.errors[e.path] != null)
+ Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)),
+ ],
+ ),
+ ),
+ ]);
+
+ final real = !row.isRoot && !row.isUp;
+ Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded(
+ flex: flex,
+ child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis),
+ );
+ final cells = !look.columns || compact || !real
+ ? [Expanded(child: nameCell())]
+ : look == ExplorerStyle.windows
+ ? [
+ Expanded(flex: 6, child: nameCell()),
+ cell(_date(e.modified), 3),
+ cell(typeLabel(e), 2),
+ cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end),
+ ]
+ : [
+ Expanded(flex: 6, child: nameCell()),
+ cell(e.isDir ? '' : e.ext, 1),
+ cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end),
+ const SizedBox(width: 8),
+ cell(_date(e.modified), 3),
+ ];
+
return GestureDetector(
- onSecondaryTapUp: (d) => _menu(pane, e, d.globalPosition),
- onLongPressStart: (d) => _menu(pane, e, d.globalPosition),
+ onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null,
+ onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null,
child: Material(
color: marked
? JjColors.accent.withValues(alpha: 0.18)
: isFocused
? JjColors.panel
: Colors.transparent,
- child: InkWell(
- onTap: () => _tap(pane, e),
- child: Padding(
- padding: EdgeInsets.only(left: 6.0 + row.depth * 16, right: 4),
- child: Row(children: [
- SizedBox(
- width: 18,
- child: e.isDir
- ? (loading
- ? const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))
- : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim))
- : null,
+ child: Row(children: [
+ Expanded(
+ child: InkWell(
+ onTap: () => _onTap(pane, row, list: list),
+ // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
+ onDoubleTap: _selectMode ? () => _onOpen(pane, row, list: list) : null,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 6, right: 4),
+ child: Row(children: cells),
),
- const SizedBox(width: 4),
- SizedBox(width: 56, height: 44, child: Center(child: _icon(e, row.isRoot, open))),
- const SizedBox(width: 8),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
- color: isCurrent ? JjColors.accent : null)),
- if (!e.isDir)
- Row(children: [
- Text('${formatSize(e.size)} · ${_date(e.modified)}',
- style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
- if (video) ...[
- const SizedBox(width: 8),
- Flexible(child: _MetaText(c: c, path: e.path)),
- ],
- ]),
- if (e.isDir && pane.errors[e.path] != null)
- Text(tr('읽을 수 없음'), style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
- ],
- ),
- ),
- if (!row.isRoot)
- IconButton(
- tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
- visualDensity: VisualDensity.compact,
- icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked,
- size: 20, color: marked ? JjColors.accent : JjColors.textDim),
- onPressed: () => setState(() {
- _active = _panes.indexOf(pane);
- marked ? pane.marked.remove(e.path) : pane.marked.add(e.path);
- }),
- ),
- ]),
+ ),
),
- ),
+ // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
+ if (real && !compact)
+ SizedBox(
+ width: 36,
+ child: IconButton(
+ tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
+ visualDensity: VisualDensity.compact,
+ padding: EdgeInsets.zero,
+ icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked,
+ size: look.rich ? 20 : 16, color: marked ? JjColors.accent : JjColors.textDim),
+ onPressed: () => setState(() {
+ _active = _panes.indexOf(pane);
+ marked ? pane.marked.remove(e.path) : pane.marked.add(e.path);
+ }),
+ ),
+ )
+ else if (!compact)
+ const SizedBox(width: 36),
+ ]),
),
);
}
- Widget _icon(FileEntry e, bool isRoot, bool open) {
- if (isRoot) return const Icon(Icons.sd_storage, color: JjColors.accent, size: 28);
- if (e.isDir) return Icon(open ? Icons.folder_open : Icons.folder, color: Colors.amber, size: 30);
- if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
- const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
- if (images.contains(e.ext)) {
- return ClipRRect(
- borderRadius: BorderRadius.circular(3),
- child: Image.file(File(e.path),
- width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
- errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
- );
+ Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
+ final look = _look;
+ if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
+ if (isRoot) return Icon(Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
+ // X-plore: 동영상 썸네일 · 그림 미리 보기
+ if (look.rich && !e.isDir) {
+ if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
+ const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
+ if (images.contains(e.ext)) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(3),
+ child: Image.file(File(e.path),
+ width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
+ errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
+ );
+ }
}
- final icon = switch (e.ext) {
- 'srt' || 'ass' || 'ssa' || 'smi' || 'sami' || 'vtt' => Icons.subtitles_outlined,
- 'mp3' || 'm4a' || 'flac' || 'wav' || 'ogg' || 'aac' || 'opus' => Icons.audiotrack_outlined,
- 'pdf' => Icons.picture_as_pdf_outlined,
- 'zip' || '7z' || 'rar' || 'tar' || 'gz' => Icons.folder_zip_outlined,
- 'apk' => Icons.android,
- 'txt' || 'log' || 'md' || 'json' || 'xml' => Icons.description_outlined,
- _ => Icons.insert_drive_file_outlined,
- };
- return Icon(icon, size: 28, color: JjColors.textDim);
+ final (icon, color) = fileIcon(look, e, open: open);
+ return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color);
}
}
diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart
index b7837c0..52370cd 100644
--- a/lib/ui/settings_page.dart
+++ b/lib/ui/settings_page.dart
@@ -22,6 +22,7 @@ import 'language_settings.dart';
import 'app_actions.dart';
import 'cef_setup.dart';
import 'cleanup_dialog.dart';
+import 'explorer_look.dart' show ExplorerStyle;
import 'theme.dart';
import '../l10n/tr.dart';
@@ -554,6 +555,47 @@ class _SettingsPageState extends State {
),
],
]),
+ _group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [
+ ListTile(
+ title: Text(tr('누르기')),
+ subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). '
+ 'Ctrl + 클릭: 여러 개 고르기')),
+ trailing: DropdownButton(
+ value: s.explorerClick,
+ items: [
+ DropdownMenuItem(value: 'select', child: Text(tr('한 번: 선택 · 두 번: 실행 (기본)'))),
+ DropdownMenuItem(value: 'open', child: Text(tr('한 번: 바로 실행'))),
+ ],
+ onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!),
+ ),
+ ),
+ ListTile(
+ title: Text(tr('스타일')),
+ subtitle: Text(tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · '
+ 'Total Commander (촘촘한 목록 · [폴더])')),
+ trailing: DropdownButton(
+ value: s.explorerStyle,
+ items: [
+ for (final st in ExplorerStyle.values)
+ DropdownMenuItem(value: st.name, child: Text(tr(st.label))),
+ ],
+ onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!),
+ ),
+ ),
+ ListTile(
+ title: Text(tr('창 배치')),
+ subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')),
+ trailing: DropdownButton(
+ value: s.explorerLayout,
+ items: [
+ DropdownMenuItem(value: 'dual', child: Text(tr('두 창'))),
+ DropdownMenuItem(value: 'split', child: Text(tr('폴더 + 파일 목록'))),
+ DropdownMenuItem(value: 'single', child: Text(tr('한 창'))),
+ ],
+ onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
+ ),
+ ),
+ ]),
_group('download', Icons.download_outlined, tr('다운로드'), [
if (desk)
SwitchListTile(
@@ -791,7 +833,7 @@ class _SettingsPageState extends State {
final _groupKeys = {};
static const _groupIds = [
- 'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'download', 'run', 'cleanup', 'about', //
+ 'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'download', 'run', 'cleanup', 'about', //
];
String _groupTitle(String id) => switch (id) {
@@ -801,6 +843,7 @@ class _SettingsPageState extends State {
'subtitle' => tr('자막 (AI · 인터넷)'),
'play' => tr('재생'),
'browser' => tr('웹 브라우저'),
+ 'files' => tr('파일 탐색기'),
'download' => tr('다운로드'),
'run' => tr('실행 · 종료'),
'cleanup' => tr('저장 공간 정리'),
diff --git a/test/explorer_test.dart b/test/explorer_test.dart
index d47e512..e718213 100644
--- a/test/explorer_test.dart
+++ b/test/explorer_test.dart
@@ -53,19 +53,27 @@ void main() {
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
}
- testWidgets('두 창: 폴더 펼치기 · 파일 누르면 기본 앱 · 길게 눌러 다른 앱으로 · 다른 창으로 복사', (tester) async {
+ testWidgets('두 창 (선택 모드): 한 번 = 선택 · 화살표 = 펼치기 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async {
await open(tester);
// 두 창 + 가운데 버튼 줄
expect(find.text('상위 폴더'), findsOneWidget);
expect(find.text('doc.txt'), findsOneWidget);
- // 폴더를 누르면 그 자리에서 펼친다
+ // 폴더를 한 번 누르면 고르기만 (펼치지 않음)
await act(tester, () => tester.tap(find.text('sub')));
+ await settle(tester, () => false, rounds: 15);
+ expect(find.text('inner.txt'), findsNothing);
+ // › 화살표로 펼친다
+ await act(tester, () => tester.tap(chevronOf('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget);
- // 파일 (동영상 아님) 을 누르면 기본 앱으로
+ // 파일을 한 번 누르면 고르기만 (실행 안 함)
await act(tester, () => tester.tap(find.text('doc.txt')));
+ await settle(tester, () => false, rounds: 15);
+ expect(shell.opened, isEmpty);
+ // 두 번 누르면 기본 앱으로 실행
+ await doubleTap(tester, find.text('doc.txt'));
await settle(tester, () => shell.opened.isNotEmpty);
expect(shell.opened.last, (p.join(left, 'doc.txt'), false));
@@ -73,13 +81,14 @@ void main() {
await tester.longPress(find.text('doc.txt'));
await tester.pumpAndSettle();
expect(find.text('다른 창으로 복사'), findsOneWidget);
+ expect(find.text('삭제'), findsWidgets);
await act(tester, () => tester.tap(find.text('다른 앱으로 열기')));
await settle(tester, () => shell.opened.length == 2);
expect(shell.opened.last, (p.join(left, 'doc.txt'), true));
// doc.txt 를 고른 채 [복사] → 확인 → 오른쪽 창 폴더에 생긴다
await act(tester, () => tester.tap(find.text('doc.txt')));
- await tester.pump();
+ await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle();
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
@@ -88,11 +97,61 @@ void main() {
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
});
+ testWidgets('바로 실행 모드: 폴더를 누르면 펼치고, 파일을 누르면 바로 실행', (tester) async {
+ c.settings.explorerClick = 'open';
+ await open(tester);
+ await act(tester, () => tester.tap(find.text('sub')));
+ await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
+ expect(find.text('inner.txt'), findsOneWidget);
+ await act(tester, () => tester.tap(find.text('doc.txt')));
+ await settle(tester, () => shell.opened.isNotEmpty);
+ expect(shell.opened.single, (p.join(left, 'doc.txt'), false));
+ });
+
+ testWidgets('폴더 + 파일 목록 배치 · Windows 탐색기 / Total Commander 스타일', (tester) async {
+ c.settings
+ ..explorerLayout = 'split'
+ ..explorerStyle = 'windows';
+ await open(tester);
+ // 왼쪽 트리는 폴더만, 오른쪽 목록은 고른 폴더 (left) 의 내용
+ expect(find.text('sub'), findsNWidgets(2)); // 트리 + 목록
+ expect(find.text('doc.txt'), findsOneWidget); // 목록에만
+ expect(find.text('수정한 날짜'), findsOneWidget); // Windows 탐색기 열 머리
+ expect(find.text('TXT 파일'), findsOneWidget);
+ expect(find.text('상위 폴더'), findsOneWidget); // 버튼 줄은 두 창 사이
+
+ // 왼쪽 트리에서 sub 를 고르면 오른쪽 목록이 그 폴더로
+ await act(tester, () => tester.tap(find.text('sub').first));
+ await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
+ expect(find.text('inner.txt'), findsOneWidget);
+ expect(find.text('doc.txt'), findsNothing);
+ // 목록의 ".." 을 누르면 상위 폴더로
+ await act(tester, () => tester.tap(find.text('..')));
+ await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
+ expect(find.text('doc.txt'), findsOneWidget);
+ // 목록의 폴더를 두 번 누르면 들어간다
+ await doubleTap(tester, find.text('sub').last);
+ await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
+ expect(find.text('inner.txt'), findsOneWidget);
+
+ // Total Commander: [폴더] · · 확장자 칸
+ await act(tester, () => tester.tap(find.text('..')));
+ await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
+ await c.updateSettings((s) => s.explorerStyle = 'totalcmd');
+ await settle(tester, () => find.text('[sub]').evaluate().length == 2);
+ expect(find.text('[sub]'), findsNWidgets(2));
+ expect(find.text(''), findsOneWidget);
+ expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름
+ expect(find.text('txt'), findsOneWidget); // 확장자 칸
+ expect(find.text('확장자'), findsOneWidget);
+ });
+
testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async {
await open(tester);
// 동그라미로 doc.txt · sub 표시 (반드시 이름으로 찾은 줄의 버튼만: 목록에는 시험 폴더 위쪽의 실제 폴더도 보인다)
await act(tester, () => tester.tap(markOf('sub')));
await act(tester, () => tester.tap(markOf('doc.txt')));
+ // 표시 버튼은 두 번 누르기를 기다리지 않고 바로
expect(find.text('2개 표시함'), findsOneWidget);
await act(tester, () => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle();
@@ -157,7 +216,7 @@ void main() {
/// [name] 줄의 "표시" 동그라미 버튼
Finder markOf(String name) => find.descendant(
- of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first,
+ of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first,
matching: find.byTooltip('표시 (여러 개 고르기)'),
);
@@ -167,9 +226,25 @@ Future act(WidgetTester tester, Future Function() f) async {
await tester.pump();
}
+/// [name] 트리 줄의 › (펼치기) 화살표
+Finder chevronOf(String name) => find.descendant(
+ of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first,
+ matching: find.byIcon(Icons.chevron_right),
+ );
+
+/// 두 번 누르기 (실제 비동기 구역에서: 그때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게)
+Future doubleTap(WidgetTester tester, Finder f) async {
+ await tester.runAsync(() async {
+ await tester.tap(f);
+ await Future.delayed(const Duration(milliseconds: 80));
+ await tester.tap(f);
+ });
+ await tester.pump();
+}
+
/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신
-Future settle(WidgetTester tester, bool Function() done) async {
- for (var i = 0; i < 100; i++) {
+Future settle(WidgetTester tester, bool Function() done, {int rounds = 100}) async {
+ for (var i = 0; i < rounds; i++) {
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30)));
await tester.pump();
if (done()) break;