From 99024d3c6eac78a8c17d72d006fdb42e3197ceb5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:14:05 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EA=B8=B0=20(X-plore=20=EC=B0=B8=EA=B3=A0):=20=EB=91=90=20?= =?UTF-8?q?=EC=B0=BD=20=ED=8A=B8=EB=A6=AC=20=C2=B7=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EA=B8=B0=EB=8A=A5=20=EB=B2=84=ED=8A=BC=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BD=20=EB=B0=B0=EC=B9=98=20=C2=B7=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위쪽 공통 버튼에 [파일 탐색기], 환경 설정 > 홈 화면에 "파일 탐색기" (앱을 켜면 바로) - 두 창 (트리: 누르면 그 자리에서 펼치기 · 접기), 저장 장치 고르기 (Windows 드라이브 · Android 저장소) - 동영상: 누르면 기본 동작 (내장 플레이어 또는 확장자별 재생 프로그램), 길게 누르기 · 오른쪽 클릭 메뉴 (내장 플레이어로 재생 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 변경 · 복사 / 이동 · 삭제 · 정보) - 썸네일 · 해상도 · 길이 (작업 중에는 썸네일을 만들지 않음), 그림 파일 미리 보기 - 가운데 버튼: 상위 폴더 · 새로 고침 · 재생 · MKV 목록에 추가 · 새 폴더 · 이름 변경 · 복사 · 이동 · 삭제 · 찾기 · 정렬 기준 · 숨은 항목 · 내역 · 창 배치 · 버튼 구성 - 창 배치: 두 창 / 한 창, 좌우 / 위아래 / 화면 모양 따라, 버튼 줄 두 창 사이 / 끝 / 숨김 - 버튼 구성: 보일 버튼 · 순서 - 다른 앱으로 열기: Windows "연결 프로그램" 창 · Android 앱 고르기 (FileProvider, SD 카드 포함) - 여러 개 지울 때 확인 창에 이름 · 폴더를 보여 준다 Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 33 + .../app/src/main/res/xml/jj_file_paths.xml | 3 +- assets/l10n/en.json | 74 + assets/l10n/ja.json | 74 + assets/l10n/zh-Hans.json | 74 + integration_test/explorer_shot_test.dart | 48 + lib/app/app_controller.dart | 6 +- lib/app/settings.dart | 46 +- lib/core/file_ops.dart | 284 ++++ lib/main.dart | 9 +- lib/platform/android/android_shell.dart | 16 + lib/platform/windows/desktop_shell.dart | 15 + lib/services/app_shell.dart | 6 + lib/ui/app_actions.dart | 36 +- lib/ui/explorer_page.dart | 1359 +++++++++++++++++ lib/ui/player_page.dart | 6 +- lib/ui/settings_page.dart | 1 + test/batch_test.dart | 2 +- test/explorer_test.dart | 178 +++ test/file_ops_test.dart | 110 ++ 20 files changed, 2363 insertions(+), 17 deletions(-) create mode 100644 integration_test/explorer_shot_test.dart create mode 100644 lib/core/file_ops.dart create mode 100644 lib/ui/explorer_page.dart create mode 100644 test/explorer_test.dart create mode 100644 test/file_ops_test.dart diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index a2b23ac..a776eee 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -64,6 +64,9 @@ class MainActivity : FlutterActivity() { result.error("INSTALL", e.message ?: e.toString(), null) } "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) + "openWith" -> result.success( + openWith(call.argument("path") ?: "", call.argument("choose") ?: false) + ) "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) "keepAlive" -> { keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1) @@ -259,6 +262,36 @@ class MainActivity : FlutterActivity() { return true } + /// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창). + /// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml). + private fun openWith(path: String, choose: Boolean): Boolean { + val f = File(path) + if (!f.isFile) return false + val uri = try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*" + val view = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + fun chooser(): Intent = Intent.createChooser(view, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + startActivity(if (choose) chooser() else view) + true + } catch (e: Exception) { + try { + startActivity(chooser()) + true + } catch (e2: Exception) { + false + } + } + } + private fun openFolder(path: String): Boolean { // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") val root = Environment.getExternalStorageDirectory().path diff --git a/android/app/src/main/res/xml/jj_file_paths.xml b/android/app/src/main/res/xml/jj_file_paths.xml index 2088533..af87ce3 100644 --- a/android/app/src/main/res/xml/jj_file_paths.xml +++ b/android/app/src/main/res/xml/jj_file_paths.xml @@ -1,5 +1,6 @@ - + + diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ca9b44b..a68f67d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -23,7 +23,9 @@ " 지금 설치됨 ({0}MB)": " Installed now ({0}MB)", "\"{0}\" 검색 결과 {1}개": "{1} results for \"{0}\"", "\"{0}\" 안에 만듭니다": "Created inside \"{0}\"", + "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.", "\"{0}\" 이동": "Move \"{0}\"", + "\"{0}\" 찾기 · {1}개": "Search \"{0}\" · {1}", "\"동영상 추가\" 로 파일을 선택하세요": "Choose files with \"Add videos\"", "\"외부 브라우저로 열기\" 에 쓸 브라우저 (이 PC 에 설치된 것)": "Browser for \"Open in external browser\" (installed on this PC)", "# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# Exported from the JJ_MKVMaker in-app browser\n\n", @@ -96,6 +98,8 @@ "MKV 만들기{0}{1}": "Make MKV{0}{1}", "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "Applied when making MKVs (the video is re-encoded)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "Added {0} to the Make MKV video list.{1}", + "MKV 목록에 {0}개를 추가했습니다.": "Added {0} to the MKV list.", + "MKV 목록에 추가": "Add to MKV list", "MKV 완성": "MKV done", "MKV 화면": "MKV screen", "MKV 화면 (기본)": "MKV screen (default)", @@ -170,6 +174,10 @@ "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 선택": "{0} selected", + "{0}개 표시함": "{0} marked", + "{0}개 항목을 {1}": "{0} items {1}", + "{0}개 항목을 다음 폴더로 {1}:\n{2}": "{0} items to this folder — {1}\n{2}", + "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", @@ -178,6 +186,7 @@ "{0}줄": "{0} lines", "{0}초": "{0} s", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· Background: only hides the window and keeps downloading · converting (reopen with {0} or the tray icon)\n· Quit all: stops downloads · conversions and closes the program", + "… 외 {0}개": "… and {0} more", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)", "− 차갑게 · + 따뜻하게": "− cooler · + warmer", @@ -199,6 +208,7 @@ "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 폴더의 모든 동영상": "All videos in the same folder", "같은 폴더의 자막은 자동으로 추가됩니다": "Subtitles in the same folder are added automatically", + "거꾸로": "Reverse", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "검색": "Search", "검색 지우기": "Clear search", @@ -219,7 +229,9 @@ "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", "기계 번역": "Machine translation", "기기": "device", + "기능 버튼 줄": "Button bar", "기본": "Default", + "기본 앱으로 열기": "Open with default app", "기본 연결 프로그램": "Default app", "기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서": "Back to the default size ({0}%) · set the default size in Settings > Display", "기본 프로그램": "Default program", @@ -235,9 +247,11 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끝": "End", + "끝 (오른쪽 · 아래)": "Edge (right · bottom)", "끝 = 현재": "End = now", "끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}", "끝: {0}": "End: {0}", + "끝나지 못했습니다: {0}": "Did not finish: {0}", "나가기": "Exit", "나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn", "나중에": "Later", @@ -250,6 +264,8 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)", "내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine", "내보냈습니다: {0}": "Exported: {0}", + "내역": "History", + "내역 (최근 폴더)": "History (recent folders)", "내용": "Content", "내용 편집 · 문자셋 변환": "Edit content · convert charset", "내장": "Embedded", @@ -262,6 +278,7 @@ "내장 Chrome 엔진을 지웠습니다.": "Deleted the built-in Chrome engine.", "내장 저장소": "Internal storage", "내장 플레이어": "Built-in player", + "내장 플레이어로 재생": "Play in built-in player", "네덜란드어": "Dutch", "네팔어": "Nepali", "노랑": "Yellow", @@ -272,10 +289,14 @@ "다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser", "다른 앱에서 동영상을 열 때": "When opening a video from another app", "다른 앱에서 열기: {0}": "Opened from another app: {0}", + "다른 앱으로 열기": "Open with another app", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.", "다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})", "다른 이름으로 저장": "Save as", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", + "다른 창에서 열기": "Open in other pane", + "다른 창으로 복사": "Copy to other pane", + "다른 창으로 이동": "Move to other pane", "다른 폴더로 이동": "Move to another folder", "다시 만들기 대기로": "Back to waiting for remake", "다시 시작": "Restart", @@ -322,6 +343,7 @@ "동시 MKV 변환 수": "Simultaneous MKV conversions", "동시 다운로드 수": "Simultaneous downloads", "동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)", + "동영상": "Video", "동영상 {0}개 · 전체 선택": "{0} videos · select all", "동영상 목록 · 창 위치 저장": "Save video list · window position", "동영상 목록에 추가: {0}": "Added to the video list: {0}", @@ -337,6 +359,9 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "Drag and drop videos to add them to the list", "동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)", "되살리기": "Restore", + "두 창": "Two panes", + "두 창 배치": "Two-pane arrangement", + "두 창 사이": "Between panes", "뒤로": "Back", "등록": "Register", "등록 중 일부가 실패했습니다.": "Some registrations failed.", @@ -373,6 +398,7 @@ "미지정": "Unspecified", "바꾸면 바로 저장됩니다": "Changes are saved right away", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", + "바뀐 날": "Modified", "바뀐 내용": "Changes", "바로 재생 (기본)": "Play right away (default)", "바로 전 작업: {0}": "Previous job: {0}", @@ -393,6 +419,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", "백그라운드로 계속 (기본)": "Keep running in the background (default)", + "버튼 구성": "Button layout", "번역 모델 내려받는 중 {0}%": "Downloading translation model {0}%", "번역 모델 내려받는 중: {0}": "Downloading translation model: {0}", "번역 모델 불러오는 중": "Loading translation model", @@ -407,7 +434,14 @@ "변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?", "변환": "Convert", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", + "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", + "복사": "Copy", + "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "Copy · move works with two panes (Pane layout > Two panes).", + "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy · move (tap, or mark with the circle on the right).", "복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs", + "복사하는 중": "Copying", + "복사할까요?": "copy?", + "복사했습니다.": "copied.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", "분석 실패: {0} - {1}": "Analysis failed: {0} - {1}", "브라우저 엔진 (앱 안)": "Browser engine (in-app)", @@ -425,6 +459,7 @@ "삭제 (중지하고 받던 파일 삭제)": "Delete (stop and delete the partial file)", "삭제 (출력에서 제외)": "Delete (exclude from output)", "삭제: {0}": "Deleted: {0}", + "상위 폴더": "Parent folder", "새 버전 v{0}": "New version v{0}", "새 버전 확인: 지금 v{0} · 최신 v{1}": "Update check: current v{0} · latest v{1}", "새 버전을 확인할 수 없습니다: {0}": "Cannot check for a new version: {0}", @@ -433,6 +468,7 @@ "새 폴더": "New folder", "새 폴더 추가": "Add new folder", "새 폴더…": "New folder…", + "새로 고침": "Refresh", "새로고침 (F5)": "Reload (F5)", "색 보정": "Color correction", "색온도": "Temperature", @@ -463,6 +499,11 @@ "세로 고정": "Portrait lock", "수정": "Edit", "수정 · 이동": "Edit · move", + "숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "Even when hidden, you can change the pane layout · buttons from the ⋮ menu at the top.", + "숨김": "Hidden", + "숨은 항목 표시": "Show hidden items", + "숨은 항목을 감춥니다.": "Hidden items are hidden.", + "숨은 항목을 보입니다.": "Hidden items are shown.", "쉬는 중": "Idle", "스웨덴어": "Swedish", "스페인어": "Spanish", @@ -530,6 +571,9 @@ "오른쪽 90°": "Right 90°", "오픈 소스 라이선스": "Open source licenses", "올린 이 {0}": "Uploaded by {0}", + "옮겼습니다.": "moved.", + "옮기는 중": "Moving", + "옮길까요?": "move?", "완료": "Done", "완료 정리": "Clear completed", "완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total", @@ -538,6 +582,7 @@ "외부 브라우저": "External browser", "외부 브라우저로 열기": "Open in external browser", "외부 프로그램으로 재생: {0} ← {1}개": "Play with external program: {0} ← {1}", + "왼쪽": "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", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", @@ -561,6 +606,7 @@ "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}", "위 폴더": "Parent folder", "위로": "Up", + "위아래": "Top and bottom", "음성": "Audio", "음성 {0}개": "{0} audio", "음성 선택": "Choose audio", @@ -584,12 +630,15 @@ "이 이동 버튼 지우기": "Delete this move button", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", "이 파일용": "For this file", + "이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.", "이 페이지를 {0}(으)로 번역": "Translate this page to {0}", "이 폴더 선택": "Choose this folder", "이 폴더 전체": "This whole folder", + "이 폴더에 동영상이 없습니다.": "There are no videos in this folder.", "이 폴더에 새 폴더": "New folder in this folder", "이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", + "이 폴더의 동영상 재생": "Play videos in this folder", "이동": "Move", "이동 ({0})": "Move ({0})", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "Move buttons (bottom right of the details, stacked upward)", @@ -606,6 +655,9 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이름": "Name", "이름 바꾸기 · 이동": "Rename · move", + "이름 변경": "Rename", + "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", + "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", @@ -626,6 +678,7 @@ "일반": "General", "일본어": "Japanese", "일시정지": "Pause", + "읽을 수 없음": "Cannot read", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 감지": "Auto-detect", @@ -708,12 +761,14 @@ "전체 화면": "Full screen", "전체 화면 (F)": "Full screen (F)", "점검": "Check", + "정렬 기준": "Sort by", "정리": "Clean up", "정리 ({0})": "Clean up ({0})", "정리…": "Clean up…", "정리할 파일을 찾는 중…": "Looking for files to clean up…", "정리할 파일이 없었습니다.": "There was nothing to clean up.", "정리했습니다: 파일 {0}개 · {1}": "Cleaned up: {0} files · {1}", + "정보": "Info", "정사각 1:1": "Square 1:1", "정지": "Stop", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "Not set: you choose it the first time you press [Move] in the MKV list", @@ -725,6 +780,8 @@ "종료 중입니다.": "Quitting.", "종료 창 닫힘": "Quit window closed", "종료할까요?": "Quit?", + "종류 (확장자)": "Type (extension)", + "좌우": "Side by side", "주소": "Address", "주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)", "준비 중": "Preparing", @@ -755,17 +812,25 @@ "지금: {0}": "Now: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 작업 기록": "Old job logs", + "지우지 못했습니다: {0}": "Could not delete: {0}", + "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", "직접 입력": "Custom", "직접 입력…": "Custom…", "진행 중인 작업 없음": "No jobs in progress", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?", + "창": "Panes", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)", + "창 배치": "Pane layout", + "창 배치 · 버튼 구성": "Pane layout · buttons", "창 보이기 / 숨기기 단축키": "Hotkey to show / hide the window", "창 숨김 → 프로세스 종료": "Window hidden → process exit", "창 종료": "Close window", + "찾기": "Search", + "찾기: {0}": "Search: {0}", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", + "찾은 것이 없습니다.": "Nothing found.", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "No subtitles found. Try the original English title or add languages.", "채도": "Saturation", "채우기 (가운데 잘라냄)": "Fill (crop the center)", @@ -784,6 +849,7 @@ "추가 ({0})": "Add ({0})", "추가 취소": "Cancel adding", "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "Added: {0} ({1} embedded subtitles, {2} subtitles in the same folder)", + "추가할 동영상이 없습니다.": "No videos to add.", "출력: {0}": "Output: {0}", "취소": "Cancel", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)", @@ -791,6 +857,7 @@ "취소했습니다.": "Cancelled.", "켜져 있는 창에서 (기본)": "In the open window (default)", "코덱": "Codec", + "크기": "Size", "크메르어": "Khmer", "타갈로그어": "Tagalog", "타밀어": "Tamil", @@ -804,9 +871,12 @@ "트랙 {0}": "Track {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", "트레이 정리": "Clean up tray", + "파일 관리자에서 보기": "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)", "파일 확인 중…": "Checking files…", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", "팝업 끄기": "Close pop-up", @@ -833,8 +903,10 @@ "폴더가 아닙니다: {0}": "Not a folder: {0}", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", "폴란드어": "Polish", + "표시 (여러 개 고르기)": "Mark (select several)", "표시 이름": "Display name", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder", + "표시 지우기": "Clear marks", "표준": "Standard", "푸는 중": "Extracting", "프랑스어": "French", @@ -854,6 +926,7 @@ "필수 프로그램 점검": "Check required programs", "필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.", "하위 폴더가 없습니다": "No subfolders", + "한 창": "One pane", "한국어": "Korean", "한글": "Hangul", "해제": "Unset", @@ -870,6 +943,7 @@ "화면": "Display", "화면 · 색 보정": "Frame · color", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.", + "화면 모양 따라": "Follow screen shape", "화면 방향": "Screen orientation", "화면 비율": "Aspect ratio", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "Aspect ratio (landscape · portrait) · rotation · brightness · contrast · saturation · temperature", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d37e1b0..9275a42 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -23,7 +23,9 @@ " 지금 설치됨 ({0}MB)": " インストール済み({0}MB)", "\"{0}\" 검색 결과 {1}개": "「{0}」の検索結果 {1}件", "\"{0}\" 안에 만듭니다": "「{0}」の中に作成します", + "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。", "\"{0}\" 이동": "「{0}」を移動", + "\"{0}\" 찾기 · {1}개": "「{0}」を検索 · {1}件", "\"동영상 추가\" 로 파일을 선택하세요": "「動画を追加」でファイルを選んでください", "\"외부 브라우저로 열기\" 에 쓸 브라우저 (이 PC 에 설치된 것)": "「外部ブラウザーで開く」に使うブラウザー(この PC にインストール済み)", "# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# JJ_MKVMaker アプリ内ブラウザーから書き出し\n\n", @@ -96,6 +98,8 @@ "MKV 만들기{0}{1}": "MKV 作成{0}{1}", "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "MKV 作成に適用されます(映像を再エンコード)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "MKV 作成の動画リストに {0}個を追加しました。{1}", + "MKV 목록에 {0}개를 추가했습니다.": "MKV リストに {0} 件を追加しました。", + "MKV 목록에 추가": "MKV リストに追加", "MKV 완성": "MKV 完成", "MKV 화면": "MKV 画面", "MKV 화면 (기본)": "MKV 画面(既定)", @@ -170,6 +174,10 @@ "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 선택": "{0}個選択", + "{0}개 표시함": "{0} 件をマーク", + "{0}개 항목을 {1}": "{0} 件の項目を{1}", + "{0}개 항목을 다음 폴더로 {1}:\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", + "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", @@ -178,6 +186,7 @@ "{0}줄": "{0}行", "{0}초": "{0}秒", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", + "… 외 {0}개": "… ほか {0} 件", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", "− 차갑게 · + 따뜻하게": "− 寒色 · + 暖色", @@ -199,6 +208,7 @@ "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 폴더의 모든 동영상": "同じフォルダーのすべての動画", "같은 폴더의 자막은 자동으로 추가됩니다": "同じフォルダーの字幕は自動で追加されます", + "거꾸로": "逆順", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "검색": "検索", "검색 지우기": "検索をクリア", @@ -219,7 +229,9 @@ "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "기계 번역": "機械翻訳", "기기": "端末", + "기능 버튼 줄": "機能ボタンの列", "기본": "既定", + "기본 앱으로 열기": "既定のアプリで開く", "기본 연결 프로그램": "既定のアプリ", "기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서": "既定の大きさ({0}%)に戻す・既定の大きさは 環境設定 > 画面 で", "기본 프로그램": "既定のプログラム", @@ -235,9 +247,11 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끝": "終了", + "끝 (오른쪽 · 아래)": "端 (右 · 下)", "끝 = 현재": "終了 = 現在", "끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}", "끝: {0}": "終了: {0}", + "끝나지 못했습니다: {0}": "完了できませんでした: {0}", "나가기": "終了する", "나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします", "나중에": "後で", @@ -250,6 +264,8 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", "내보냈습니다: {0}": "書き出しました: {0}", + "내역": "履歴", + "내역 (최근 폴더)": "履歴 (最近のフォルダー)", "내용": "内容", "내용 편집 · 문자셋 변환": "内容の編集・文字コード変換", "내장": "内蔵", @@ -262,6 +278,7 @@ "내장 Chrome 엔진을 지웠습니다.": "内蔵 Chrome エンジンを削除しました。", "내장 저장소": "内部ストレージ", "내장 플레이어": "内蔵プレーヤー", + "내장 플레이어로 재생": "内蔵プレーヤーで再生", "네덜란드어": "オランダ語", "네팔어": "ネパール語", "노랑": "黄色", @@ -272,10 +289,14 @@ "다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む", "다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき", "다른 앱에서 열기: {0}": "他のアプリから開く: {0}", + "다른 앱으로 열기": "別のアプリで開く", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。", "다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)", "다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", + "다른 창에서 열기": "もう一方のペインで開く", + "다른 창으로 복사": "もう一方のペインへコピー", + "다른 창으로 이동": "もう一方のペインへ移動", "다른 폴더로 이동": "他のフォルダーに移動", "다시 만들기 대기로": "作り直し待ちに戻す", "다시 시작": "再起動", @@ -322,6 +343,7 @@ "동시 MKV 변환 수": "同時 MKV 変換数", "동시 다운로드 수": "同時ダウンロード数", "동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)", + "동영상": "動画", "동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択", "동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存", "동영상 목록에 추가: {0}": "動画リストに追加: {0}", @@ -337,6 +359,9 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "動画をドラッグ&ドロップするとリストに追加されます", "동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)", "되살리기": "元に戻す", + "두 창": "2 ペイン", + "두 창 배치": "2 ペインの配置", + "두 창 사이": "ペインの間", "뒤로": "戻る", "등록": "登録", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", @@ -373,6 +398,7 @@ "미지정": "未指定", "바꾸면 바로 저장됩니다": "変更するとすぐ保存されます", "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", + "바뀐 날": "更新日時", "바뀐 내용": "変更内容", "바로 재생 (기본)": "すぐ再生(既定)", "바로 전 작업: {0}": "直前の作業: {0}", @@ -393,6 +419,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", + "버튼 구성": "ボタン構成", "번역 모델 내려받는 중 {0}%": "翻訳モデルをダウンロード中 {0}%", "번역 모델 내려받는 중: {0}": "翻訳モデルをダウンロード中: {0}", "번역 모델 불러오는 중": "翻訳モデルを読み込み中", @@ -407,7 +434,14 @@ "변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?", "변환": "変換", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", + "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。", + "복사": "コピー", + "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "コピー · 移動は 2 ペインで使います (ペインの配置 > 2 ペイン)。", + "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピー · 移動する項目を選んでください (タップ、または右の丸でマーク)。", "복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード", + "복사하는 중": "コピー中", + "복사할까요?": "コピーしますか?", + "복사했습니다.": "コピーしました。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", "분석 실패: {0} - {1}": "分析失敗: {0} - {1}", "브라우저 엔진 (앱 안)": "ブラウザーエンジン(アプリ内)", @@ -425,6 +459,7 @@ "삭제 (중지하고 받던 파일 삭제)": "削除(中止してダウンロード中のファイルを削除)", "삭제 (출력에서 제외)": "削除(出力から除外)", "삭제: {0}": "削除: {0}", + "상위 폴더": "上のフォルダー", "새 버전 v{0}": "新しいバージョン v{0}", "새 버전 확인: 지금 v{0} · 최신 v{1}": "新しいバージョンの確認: 現在 v{0} · 最新 v{1}", "새 버전을 확인할 수 없습니다: {0}": "新しいバージョンを確認できません: {0}", @@ -433,6 +468,7 @@ "새 폴더": "新しいフォルダー", "새 폴더 추가": "新しいフォルダーを追加", "새 폴더…": "新しいフォルダー…", + "새로 고침": "更新", "새로고침 (F5)": "再読み込み(F5)", "색 보정": "カラー補正", "색온도": "色温度", @@ -463,6 +499,11 @@ "세로 고정": "縦向き固定", "수정": "編集", "수정 · 이동": "編集・移動", + "숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "非表示にしても、上の ⋮ メニューからペインの配置 · ボタン構成を変えられます。", + "숨김": "非表示", + "숨은 항목 표시": "隠し項目を表示", + "숨은 항목을 감춥니다.": "隠し項目を隠します。", + "숨은 항목을 보입니다.": "隠し項目を表示します。", "쉬는 중": "休止中", "스웨덴어": "スウェーデン語", "스페인어": "スペイン語", @@ -530,6 +571,9 @@ "오른쪽 90°": "右 90°", "오픈 소스 라이선스": "オープンソースライセンス", "올린 이 {0}": "投稿者 {0}", + "옮겼습니다.": "移動しました。", + "옮기는 중": "移動中", + "옮길까요?": "移動しますか?", "완료": "完了", "완료 정리": "完了を整理", "완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}", @@ -538,6 +582,7 @@ "외부 브라우저": "外部ブラウザー", "외부 브라우저로 열기": "外部ブラウザーで開く", "외부 프로그램으로 재생: {0} ← {1}개": "外部プログラムで再生: {0} ← {1}個", + "왼쪽": "左", "왼쪽 90°": "左 90°", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", @@ -561,6 +606,7 @@ "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}", "위 폴더": "上のフォルダー", "위로": "上へ", + "위아래": "上下", "음성": "音声", "음성 {0}개": "音声 {0}個", "음성 선택": "音声を選択", @@ -584,12 +630,15 @@ "이 이동 버튼 지우기": "この移動ボタンを削除", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일용": "このファイル用", + "이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。", "이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳", "이 폴더 선택": "このフォルダーを選択", "이 폴더 전체": "このフォルダー全体", + "이 폴더에 동영상이 없습니다.": "このフォルダーに動画がありません。", "이 폴더에 새 폴더": "このフォルダーに新しいフォルダー", "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", + "이 폴더의 동영상 재생": "このフォルダーの動画を再生", "이동": "移動", "이동 ({0})": "移動({0})", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移動ボタン(詳細の右下、上に 1つずつ)", @@ -606,6 +655,9 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이름": "名前", "이름 바꾸기 · 이동": "名前の変更・移動", + "이름 변경": "名前の変更", + "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", + "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", @@ -626,6 +678,7 @@ "일반": "一般", "일본어": "日本語", "일시정지": "一時停止", + "읽을 수 없음": "読み取れません", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 감지": "自動検出", @@ -708,12 +761,14 @@ "전체 화면": "全画面", "전체 화면 (F)": "全画面(F)", "점검": "点検", + "정렬 기준": "並べ替え", "정리": "整理", "정리 ({0})": "整理({0})", "정리…": "整理…", "정리할 파일을 찾는 중…": "整理するファイルを探しています…", "정리할 파일이 없었습니다.": "整理するファイルはありませんでした。", "정리했습니다: 파일 {0}개 · {1}": "整理しました: ファイル {0}個・{1}", + "정보": "情報", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます", @@ -725,6 +780,8 @@ "종료 중입니다.": "終了しています。", "종료 창 닫힘": "終了ウィンドウを閉じました", "종료할까요?": "終了しますか?", + "종류 (확장자)": "種類 (拡張子)", + "좌우": "左右", "주소": "アドレス", "주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)", "준비 중": "準備中", @@ -755,17 +812,25 @@ "지금: {0}": "現在: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 작업 기록": "過去の作業記録", + "지우지 못했습니다: {0}": "削除できませんでした: {0}", + "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", "직접 입력": "直接入力", "직접 입력…": "直接入力…", "진행 중인 작업 없음": "進行中の作業なし", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?", + "창": "ペイン", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)", + "창 배치": "ペインの配置", + "창 배치 · 버튼 구성": "ペインの配置 · ボタン構成", "창 보이기 / 숨기기 단축키": "ウィンドウ表示 / 非表示のショートカット", "창 숨김 → 프로세스 종료": "ウィンドウ非表示 → プロセス終了", "창 종료": "ウィンドウを閉じる", + "찾기": "検索", + "찾기: {0}": "検索: {0}", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", + "찾은 것이 없습니다.": "見つかりませんでした。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "字幕が見つかりませんでした。タイトルを英語の原題に変えるか、言語を追加してみてください。", "채도": "彩度", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", @@ -784,6 +849,7 @@ "추가 ({0})": "追加({0})", "추가 취소": "追加をキャンセル", "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "追加: {0}(内蔵字幕 {1}個、同じフォルダーの字幕 {2}個)", + "추가할 동영상이 없습니다.": "追加する動画がありません。", "출력: {0}": "出力: {0}", "취소": "キャンセル", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)", @@ -791,6 +857,7 @@ "취소했습니다.": "キャンセルしました。", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", "코덱": "コーデック", + "크기": "サイズ", "크메르어": "クメール語", "타갈로그어": "タガログ語", "타밀어": "タミル語", @@ -804,9 +871,12 @@ "트랙 {0}": "トラック {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", "트레이 정리": "トレイの後片付け", + "파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "ファイルアプリの「アプリで開く」・共有で JJ_MKVMaker を選んだとき", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "ファイルの場所がわからないため再生のみ行います。MKV を作成するには「動画を追加」で選んでください。", "파일 이름": "ファイル名", + "파일 탐색기": "ファイルエクスプローラー", + "파일 탐색기 (지금 여기)": "ファイルエクスプローラー (現在地)", "파일 확인 중…": "ファイルを確認中…", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", "팝업 끄기": "ポップアップを閉じる", @@ -833,8 +903,10 @@ "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴란드어": "ポーランド語", + "표시 (여러 개 고르기)": "マーク (複数選択)", "표시 이름": "表示名", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します", + "표시 지우기": "マークを解除", "표준": "標準", "푸는 중": "展開中", "프랑스어": "フランス語", @@ -854,6 +926,7 @@ "필수 프로그램 점검": "必須プログラムの点検", "필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。", "하위 폴더가 없습니다": "サブフォルダーがありません", + "한 창": "1 ペイン", "한국어": "韓国語", "한글": "ハングル", "해제": "解除", @@ -870,6 +943,7 @@ "화면": "画面", "화면 · 색 보정": "画面・カラー補正", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。", + "화면 모양 따라": "画面の形に合わせる", "화면 방향": "画面の向き", "화면 비율": "画面比率", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比率(横・縦)・回転・明るさ・コントラスト・彩度・色温度", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 00d6a93..04f1a26 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -23,7 +23,9 @@ " 지금 설치됨 ({0}MB)": " 已安装({0}MB)", "\"{0}\" 검색 결과 {1}개": "“{0}”的搜索结果 {1} 个", "\"{0}\" 안에 만듭니다": "在“{0}”中创建", + "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。", "\"{0}\" 이동": "移动“{0}”", + "\"{0}\" 찾기 · {1}개": "搜索\"{0}\" · {1}个", "\"동영상 추가\" 로 파일을 선택하세요": "请用“添加视频”选择文件", "\"외부 브라우저로 열기\" 에 쓸 브라우저 (이 PC 에 설치된 것)": "“在外部浏览器中打开”使用的浏览器(本机已安装)", "# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# 从 JJ_MKVMaker 内置浏览器导出\n\n", @@ -96,6 +98,8 @@ "MKV 만들기{0}{1}": "制作 MKV{0}{1}", "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "应用于制作 MKV(视频将重新编码)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "已向制作 MKV 的视频列表添加 {0} 个。{1}", + "MKV 목록에 {0}개를 추가했습니다.": "已将 {0} 个添加到 MKV 列表。", + "MKV 목록에 추가": "添加到 MKV 列表", "MKV 완성": "MKV 完成", "MKV 화면": "MKV 界面", "MKV 화면 (기본)": "MKV 界面(默认)", @@ -170,6 +174,10 @@ "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 선택": "已选 {0} 个", + "{0}개 표시함": "已标记 {0} 个", + "{0}개 항목을 {1}": "{0} 个项目{1}", + "{0}개 항목을 다음 폴더로 {1}:\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", + "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", @@ -178,6 +186,7 @@ "{0}줄": "{0} 行", "{0}초": "{0} 秒", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", + "… 외 {0}개": "… 另外 {0} 个", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", "− 차갑게 · + 따뜻하게": "− 偏冷 · + 偏暖", @@ -199,6 +208,7 @@ "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 폴더의 모든 동영상": "同一文件夹中的所有视频", "같은 폴더의 자막은 자동으로 추가됩니다": "同一文件夹中的字幕会自动添加", + "거꾸로": "倒序", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "검색": "搜索", "검색 지우기": "清除搜索", @@ -219,7 +229,9 @@ "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "기계 번역": "机器翻译", "기기": "设备", + "기능 버튼 줄": "功能按钮栏", "기본": "默认", + "기본 앱으로 열기": "用默认应用打开", "기본 연결 프로그램": "默认打开方式", "기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서": "恢复默认大小({0}%)· 默认大小在 设置 > 显示 中设置", "기본 프로그램": "默认程序", @@ -235,9 +247,11 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끝": "结束", + "끝 (오른쪽 · 아래)": "边缘 (右 · 下)", "끝 = 현재": "结束 = 当前", "끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}", "끝: {0}": "结束: {0}", + "끝나지 못했습니다: {0}": "未能完成: {0}", "나가기": "退出", "나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载", "나중에": "以后再说", @@ -250,6 +264,8 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", "내보냈습니다: {0}": "已导出: {0}", + "내역": "历史", + "내역 (최근 폴더)": "历史 (最近的文件夹)", "내용": "内容", "내용 편집 · 문자셋 변환": "编辑内容 · 转换字符集", "내장": "内嵌", @@ -262,6 +278,7 @@ "내장 Chrome 엔진을 지웠습니다.": "已删除内置 Chrome 引擎。", "내장 저장소": "内部存储", "내장 플레이어": "内置播放器", + "내장 플레이어로 재생": "用内置播放器播放", "네덜란드어": "荷兰语", "네팔어": "尼泊尔语", "노랑": "黄色", @@ -272,10 +289,14 @@ "다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签", "다른 앱에서 동영상을 열 때": "从其他应用打开视频时", "다른 앱에서 열기: {0}": "从其他应用打开: {0}", + "다른 앱으로 열기": "用其他应用打开", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。", "다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)", "다른 이름으로 저장": "另存为", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", + "다른 창에서 열기": "在另一窗格中打开", + "다른 창으로 복사": "复制到另一窗格", + "다른 창으로 이동": "移动到另一窗格", "다른 폴더로 이동": "移动到其他文件夹", "다시 만들기 대기로": "改回等待重新制作", "다시 시작": "重新启动", @@ -322,6 +343,7 @@ "동시 MKV 변환 수": "同时转换 MKV 数", "동시 다운로드 수": "同时下载数", "동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)", + "동영상": "视频", "동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选", "동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置", "동영상 목록에 추가: {0}": "已加入视频列表: {0}", @@ -337,6 +359,9 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "拖放视频即可加入列表", "동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)", "되살리기": "恢复", + "두 창": "双窗格", + "두 창 배치": "双窗格排列", + "두 창 사이": "窗格之间", "뒤로": "后退", "등록": "注册", "등록 중 일부가 실패했습니다.": "部分注册失败。", @@ -373,6 +398,7 @@ "미지정": "未指定", "바꾸면 바로 저장됩니다": "更改后立即保存", "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", + "바뀐 날": "修改时间", "바뀐 내용": "更改内容", "바로 재생 (기본)": "立即播放(默认)", "바로 전 작업: {0}": "上一个任务: {0}", @@ -393,6 +419,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", + "버튼 구성": "按钮配置", "번역 모델 내려받는 중 {0}%": "正在下载翻译模型 {0}%", "번역 모델 내려받는 중: {0}": "正在下载翻译模型: {0}", "번역 모델 불러오는 중": "正在加载翻译模型", @@ -407,7 +434,14 @@ "변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?", "변환": "转换", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", + "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。", + "복사": "复制", + "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "复制 · 移动需在双窗格中使用 (窗格布局 > 双窗格)。", + "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制 · 移动的项目 (点按,或用右侧圆圈标记)。", "복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址", + "복사하는 중": "正在复制", + "복사할까요?": "复制吗?", + "복사했습니다.": "已复制。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", "분석 실패: {0} - {1}": "分析失败: {0} - {1}", "브라우저 엔진 (앱 안)": "浏览器引擎(应用内)", @@ -425,6 +459,7 @@ "삭제 (중지하고 받던 파일 삭제)": "删除(停止并删除正在下载的文件)", "삭제 (출력에서 제외)": "删除(从输出中排除)", "삭제: {0}": "已删除: {0}", + "상위 폴더": "上级文件夹", "새 버전 v{0}": "新版本 v{0}", "새 버전 확인: 지금 v{0} · 최신 v{1}": "检查新版本: 当前 v{0} · 最新 v{1}", "새 버전을 확인할 수 없습니다: {0}": "无法检查新版本: {0}", @@ -433,6 +468,7 @@ "새 폴더": "新建文件夹", "새 폴더 추가": "添加新文件夹", "새 폴더…": "新建文件夹…", + "새로 고침": "刷新", "새로고침 (F5)": "刷新(F5)", "색 보정": "色彩校正", "색온도": "色温", @@ -463,6 +499,11 @@ "세로 고정": "锁定竖屏", "수정": "编辑", "수정 · 이동": "编辑 · 移动", + "숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.": "即使隐藏,也可以在顶部 ⋮ 菜单中更改窗格布局 · 按钮配置。", + "숨김": "隐藏", + "숨은 항목 표시": "显示隐藏项目", + "숨은 항목을 감춥니다.": "已隐藏隐藏项目。", + "숨은 항목을 보입니다.": "已显示隐藏项目。", "쉬는 중": "空闲", "스웨덴어": "瑞典语", "스페인어": "西班牙语", @@ -530,6 +571,9 @@ "오른쪽 90°": "向右 90°", "오픈 소스 라이선스": "开源许可", "올린 이 {0}": "上传者 {0}", + "옮겼습니다.": "已移动。", + "옮기는 중": "正在移动", + "옮길까요?": "移动吗?", "완료": "完成", "완료 정리": "清理已完成", "완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}", @@ -538,6 +582,7 @@ "외부 브라우저": "外部浏览器", "외부 브라우저로 열기": "在外部浏览器中打开", "외부 프로그램으로 재생: {0} ← {1}개": "用外部程序播放: {0} ← {1} 个", + "왼쪽": "左侧", "왼쪽 90°": "向左 90°", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", @@ -561,6 +606,7 @@ "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}", "위 폴더": "上级文件夹", "위로": "向上", + "위아래": "上下", "음성": "音频", "음성 {0}개": "音频 {0} 个", "음성 선택": "选择音轨", @@ -584,12 +630,15 @@ "이 이동 버튼 지우기": "删除此移动按钮", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일용": "适用于此文件", + "이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。", "이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}", "이 폴더 선택": "选择此文件夹", "이 폴더 전체": "整个文件夹", + "이 폴더에 동영상이 없습니다.": "此文件夹中没有视频。", "이 폴더에 새 폴더": "在此文件夹中新建文件夹", "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", + "이 폴더의 동영상 재생": "播放此文件夹中的视频", "이동": "移动", "이동 ({0})": "移动({0})", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移动按钮(详细信息右下角,向上依次排列)", @@ -606,6 +655,9 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이름": "名称", "이름 바꾸기 · 이동": "重命名 · 移动", + "이름 변경": "重命名", + "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", + "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", @@ -626,6 +678,7 @@ "일반": "常规", "일본어": "日语", "일시정지": "暂停", + "읽을 수 없음": "无法读取", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 감지": "自动检测", @@ -708,12 +761,14 @@ "전체 화면": "全屏", "전체 화면 (F)": "全屏(F)", "점검": "检查", + "정렬 기준": "排序方式", "정리": "清理", "정리 ({0})": "清理({0})", "정리…": "清理…", "정리할 파일을 찾는 중…": "正在查找可清理的文件…", "정리할 파일이 없었습니다.": "没有可清理的文件。", "정리했습니다: 파일 {0}개 · {1}": "已清理: 文件 {0} 个 · {1}", + "정보": "信息", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择", @@ -725,6 +780,8 @@ "종료 중입니다.": "正在退出。", "종료 창 닫힘": "退出窗口已关闭", "종료할까요?": "要退出吗?", + "종류 (확장자)": "类型 (扩展名)", + "좌우": "左右", "주소": "地址", "주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)", "준비 중": "准备中", @@ -755,17 +812,25 @@ "지금: {0}": "当前: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 작업 기록": "旧任务记录", + "지우지 못했습니다: {0}": "无法删除: {0}", + "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", "직접 입력": "手动输入", "직접 입력…": "手动输入…", "진행 중인 작업 없음": "没有进行中的任务", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?", + "창": "窗格", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)", + "창 배치": "窗格布局", + "창 배치 · 버튼 구성": "窗格布局 · 按钮配置", "창 보이기 / 숨기기 단축키": "显示 / 隐藏窗口的快捷键", "창 숨김 → 프로세스 종료": "窗口隐藏 → 进程退出", "창 종료": "关闭窗口", + "찾기": "搜索", + "찾기: {0}": "搜索: {0}", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", + "찾은 것이 없습니다.": "未找到。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "未找到字幕。请尝试改用英文原名或添加语言。", "채도": "饱和度", "채우기 (가운데 잘라냄)": "填充(裁切中间)", @@ -784,6 +849,7 @@ "추가 ({0})": "添加({0})", "추가 취소": "取消添加", "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "已添加: {0}(内嵌字幕 {1} 个,同文件夹字幕 {2} 个)", + "추가할 동영상이 없습니다.": "没有可添加的视频。", "출력: {0}": "输出: {0}", "취소": "取消", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)", @@ -791,6 +857,7 @@ "취소했습니다.": "已取消。", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", "코덱": "编解码器", + "크기": "大小", "크메르어": "高棉语", "타갈로그어": "他加禄语", "타밀어": "泰米尔语", @@ -804,9 +871,12 @@ "트랙 {0}": "轨道 {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", "트레이 정리": "清理托盘", + "파일 관리자에서 보기": "在文件管理器中显示", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "在文件应用的“打开方式” · 分享中选择 JJ_MKVMaker 时", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "无法确定文件位置,因此仅播放。要制作 MKV,请用“添加视频”选择。", "파일 이름": "文件名", + "파일 탐색기": "文件浏览器", + "파일 탐색기 (지금 여기)": "文件浏览器 (当前位置)", "파일 확인 중…": "正在检查文件…", "파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}", "팝업 끄기": "关闭弹出窗口", @@ -833,8 +903,10 @@ "폴더가 아닙니다: {0}": "不是文件夹: {0}", "폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", "폴란드어": "波兰语", + "표시 (여러 개 고르기)": "标记 (多选)", "표시 이름": "显示名称", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹", + "표시 지우기": "清除标记", "표준": "标准", "푸는 중": "正在解压", "프랑스어": "法语", @@ -854,6 +926,7 @@ "필수 프로그램 점검": "检查必需程序", "필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。", "하위 폴더가 없습니다": "没有子文件夹", + "한 창": "单窗格", "한국어": "韩语", "한글": "韩文", "해제": "解除", @@ -870,6 +943,7 @@ "화면": "显示", "화면 · 색 보정": "画面 · 色彩", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。", + "화면 모양 따라": "跟随屏幕形状", "화면 방향": "屏幕方向", "화면 비율": "画面比例", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比例(横屏 · 竖屏)· 旋转 · 亮度 · 对比度 · 饱和度 · 色温", diff --git a/integration_test/explorer_shot_test.dart b/integration_test/explorer_shot_test.dart new file mode 100644 index 0000000..b6c11e2 --- /dev/null +++ b/integration_test/explorer_shot_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; + +/// 파일 탐색기 화면을 실제 글꼴 · 실제 FFmpeg (썸네일 · 동영상 정보) 로 그려 PNG 로 저장 (JJ_SHOT_DIR). +/// 왼쪽 · 오른쪽 창 폴더: JJ_SHOT_LEFT · JJ_SHOT_RIGHT. 화면 배치 확인용. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('파일 탐색기 화면 그림', (tester) async { + final env = Platform.environment; + final out = env['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + 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'), + ]) { + c.settings.explorerOrientation = orient; + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + await tester.runAsync(() => tester.pumpWidget(MaterialApp( + theme: buildTheme(), + home: RepaintBoundary(key: key, child: ExplorerPage(c: c)), + ))); + // 목록 · 썸네일 · 동영상 정보가 나올 때까지 + for (var i = 0; i < 40; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 250))); + await tester.pump(); + } + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/explorer_$name.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + await tester.pumpWidget(const SizedBox()); + } + tester.view.reset(); + }); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index c2b2618..70e94aa 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1114,12 +1114,14 @@ class AppController extends ChangeNotifier { /// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null, /// 아니면 내장 플레이어용 (목록, 시작 위치). /// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생 - Future<(List, int)?> preparePlayback(List files, {bool keepOrder = false}) async { + /// [internal]: 외부 프로그램 설정과 상관없이 내장 플레이어로 (파일 탐색기의 "내장 플레이어로 재생") + Future<(List, int)?> preparePlayback(List files, + {bool keepOrder = false, bool internal = false}) async { final videos = files.where(isVideoFile).toList(); if (videos.isEmpty) return null; final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); final program = settings.externalPlayers[ext]; - if (program != null && program.isNotEmpty) { + if (!internal && program != null && program.isNotEmpty) { await services.shell.openExternal(program, videos); _log(trf('외부 프로그램으로 재생: {0} ← {1}개', [program == 'system' ? tr('기본 연결 프로그램') : p.basename(program), videos.length])); return null; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 820c6fb..24eb47f 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -91,7 +91,7 @@ class AppSettings { /// 메인 화면 아래 작업 기록 창 보이기 bool showLog = true; - /// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl]) + /// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl]), 'files' = 파일 탐색기 String startScreen = 'home'; /// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈), @@ -133,6 +133,30 @@ class AppSettings { /// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만) bool webJavaScript = true; + // ── 파일 탐색기 (X-plore 참고) ── + /// 창: 'dual' 두 창 (기본) · 'single' 한 창 + String explorerLayout = 'dual'; + + /// 두 창 배치: 'auto' 화면 모양 따라 (가로로 넓으면 좌우, 세로로 길면 위아래) · 'side' 좌우 · 'stacked' 위아래 + String explorerOrientation = 'auto'; + + /// 기능 버튼 줄: 'middle' 두 창 사이 (기본) · 'edge' 오른쪽 (위아래 배치면 아래) 끝 · 'hidden' 숨김 + String explorerToolbar = 'middle'; + + /// 기능 버튼 줄에 보일 버튼과 순서 (ExplorerButton 이름). 비어 있으면 기본 구성. + List explorerButtons = []; + + /// 정렬 · 숨긴 항목 + String explorerSort = 'name'; + bool explorerSortDesc = false; + bool explorerShowHidden = false; + + /// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창) + List explorerPaths = []; + + /// 최근에 연 폴더 (내역, 최신이 앞) + List explorerHistory = []; + /// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음 String webViewDataDir = ''; @@ -199,6 +223,15 @@ class AppSettings { 'externalBrowser': externalBrowser, 'webTranslate': webTranslate, 'webJavaScript': webJavaScript, + 'explorerLayout': explorerLayout, + 'explorerOrientation': explorerOrientation, + 'explorerToolbar': explorerToolbar, + 'explorerButtons': explorerButtons, + 'explorerSort': explorerSort, + 'explorerSortDesc': explorerSortDesc, + 'explorerShowHidden': explorerShowHidden, + 'explorerPaths': explorerPaths, + 'explorerHistory': explorerHistory, 'autoCheckUpdates': autoCheckUpdates, 'lastUpdateCheck': lastUpdateCheck, 'lastUpdateCheckVersion': lastUpdateCheckVersion, @@ -273,6 +306,17 @@ 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' + ..explorerOrientation = const ['side', 'stacked'].contains(j['explorerOrientation']) + ? j['explorerOrientation'] as String + : 'auto' + ..explorerToolbar = const ['edge', 'hidden'].contains(j['explorerToolbar']) ? j['explorerToolbar'] as String : 'middle' + ..explorerButtons = [for (final x in (j['explorerButtons'] as List?) ?? const []) '$x'] + ..explorerSort = const ['date', 'size', 'type'].contains(j['explorerSort']) ? j['explorerSort'] as String : 'name' + ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false + ..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false + ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] + ..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x'] ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? '' diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart new file mode 100644 index 0000000..4920853 --- /dev/null +++ b/lib/core/file_ops.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'playlist.dart' show naturalCompare; + +/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. + +/// 폴더 안의 항목 하나 +class FileEntry { + final String path; + final bool isDir; + final int size; + final DateTime modified; + const FileEntry(this.path, {required this.isDir, this.size = 0, required this.modified}); + + String get name => p.basename(path); + bool get hidden => name.startsWith('.'); + + /// 확장자 (소문자, 점 없이) + String get ext => isDir ? '' : p.extension(path).replaceFirst('.', '').toLowerCase(); +} + +enum SortBy { name, date, size, type } + +/// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10) +List sortEntries(List list, SortBy by, {bool descending = false}) { + int cmp(FileEntry a, FileEntry b) { + if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위 + final r = switch (by) { + SortBy.name => 0, + SortBy.date => a.modified.compareTo(b.modified), + SortBy.size => a.size.compareTo(b.size), + SortBy.type => a.ext.compareTo(b.ext), + }; + final v = r != 0 ? r : naturalCompare(a.name, b.name); + return descending ? -v : v; + } + + return [...list]..sort(cmp); +} + +/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. +Future> listEntries(String dir, {bool showHidden = false}) async { + final out = []; + await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) { + try { + final st = await e.stat(); + final isDir = st.type == FileSystemEntityType.directory; + final entry = FileEntry(e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified); + if (!showHidden && entry.hidden) continue; + out.add(entry); + } catch (_) {} + } + return out; +} + +/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록 +List<(String path, String label)> windowsDrives() => [ + for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) + if (FileSystemEntity.isDirectorySync('$c:\\')) ('$c:\\', '$c:'), + ]; + +/// 같은 이름이 있으면 "이름 (2).확장자" 로 +String uniqueTarget(String dir, String name) { + var target = p.join(dir, name); + if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; + final base = p.basenameWithoutExtension(name), ext = p.extension(name); + for (var i = 2;; i++) { + target = p.join(dir, '$base ($i)$ext'); + if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; + } +} + +/// 같은 경로인지 (Windows 는 대소문자 무시) +bool samePath(String a, String b) { + final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); + return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; +} + +/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) +bool isSameOrInside(String a, String b) { + final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); + return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); +} + +/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) +typedef CopyProgress = void Function(String current, int done, int total); + +class FileOpCancelled implements Exception { + const FileOpCancelled(); +} + +/// 복사 · 이동 · 삭제. [cancel] 을 부르면 다음 조각에서 멈춘다. +class FileOps { + bool _cancel = false; + void cancel() => _cancel = true; + + void _check() { + if (_cancel) throw const FileOpCancelled(); + } + + /// 전체 크기 (진행률용) + static Future totalSize(List paths) async { + var total = 0; + for (final s in paths) { + final t = FileSystemEntity.typeSync(s, followLinks: false); + if (t == FileSystemEntityType.directory) { + await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) { + if (e is File) { + try { + total += await e.length(); + } catch (_) {} + } + } + } else if (t == FileSystemEntityType.file) { + total += await File(s).length(); + } + } + return total; + } + + /// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다. + Future> copy(List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await totalSize(sources); + var done = 0; + final made = []; + Future copyFile(File f, String target) async { + _check(); + final out = File(target).openWrite(); + try { + try { + await for (final chunk in f.openRead()) { + _check(); + out.add(chunk); + done += chunk.length; + onProgress?.call(f.path, done, total); + } + } finally { + await out.close(); + } + _check(); + } catch (_) { + // 취소 · 실패: 만들다 만 파일을 남기지 않는다 + try { + await File(target).delete(); + } catch (_) {} + rethrow; + } + try { + await File(target).setLastModified(await f.lastModified()); + } catch (_) {} + } + + Future copyAny(String src, String target) async { + if (FileSystemEntity.isDirectorySync(src)) { + await Directory(target).create(recursive: true); + await for (final e in Directory(src).list(followLinks: false)) { + await copyAny(e.path, p.join(target, p.basename(e.path))); + } + } else { + await copyFile(File(src), target); + } + } + + for (final s in sources) { + if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { + throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s); + } + final target = uniqueTarget(destDir, p.basename(s)); + await copyAny(s, target); + made.add(target); + } + return made; + } + + /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 + Future> move(List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + for (final s in sources) { + _check(); + if (isSameOrInside(destDir, s)) throw FileSystemException('폴더를 자기 안으로 옮길 수 없습니다', s); + if (samePath(p.dirname(s), destDir)) { + made.add(s); // 이미 그 폴더에 있음 + continue; + } + final target = uniqueTarget(destDir, p.basename(s)); + try { + final t = FileSystemEntity.typeSync(s); + if (t == FileSystemEntityType.directory) { + await Directory(s).rename(target); + } else { + await File(s).rename(target); + } + made.add(target); + } on FileSystemException { + // 다른 드라이브 · 저장 장치: 복사 후 삭제 + final copied = await copy([s], destDir, onProgress: onProgress); + await delete([s]); + made.addAll(copied); + } + } + return made; + } + + Future delete(List paths) async { + for (final s in paths) { + _check(); + final t = FileSystemEntity.typeSync(s, followLinks: false); + if (t == FileSystemEntityType.directory) { + await Directory(s).delete(recursive: true); + } else if (t != FileSystemEntityType.notFound) { + await File(s).delete(); + } + } + } + + /// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. + static Future rename(String path, String newName) async { + final name = newName.trim(); + if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) { + throw FileSystemException('쓸 수 없는 이름입니다', name); + } + final target = p.join(p.dirname(path), name); + if (target == path) return path; + if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && + target.toLowerCase() != path.toLowerCase()) { + throw FileSystemException('같은 이름이 이미 있습니다', target); + } + final t = FileSystemEntity.typeSync(path); + return t == FileSystemEntityType.directory + ? (await Directory(path).rename(target)).path + : (await File(path).rename(target)).path; + } + + static Future makeFolder(String dir, String name) async { + final n = name.trim(); + if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException('쓸 수 없는 이름입니다', n); + final target = p.join(dir, n); + if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { + throw FileSystemException('같은 이름이 이미 있습니다', target); + } + return (await Directory(target).create()).path; + } +} + +/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. +Stream searchFiles(String root, String query, {bool showHidden = false, int limit = 2000}) async* { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return; + final wild = q.contains('*') || q.contains('?'); + final re = wild + ? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$') + : null; + var n = 0; + final pending = [root]; + while (pending.isNotEmpty) { + final dir = pending.removeAt(0); + List items; + try { + items = await listEntries(dir, showHidden: showHidden); + } catch (_) { + continue; + } + for (final e in sortEntries(items, SortBy.name)) { + final name = e.name.toLowerCase(); + if (re != null ? re.hasMatch(name) : name.contains(q)) { + yield e; + if (++n >= limit) return; + } + if (e.isDir) pending.add(e.path); + } + } +} + +/// "1.2GB" · "350MB" · "12KB" +String formatSize(int bytes) { + const k = 1024; + if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB'; + if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB'; + if (bytes >= k) return '${(bytes / k).round()}KB'; + return '${bytes}B'; +} diff --git a/lib/main.dart b/lib/main.dart index c8b1283..925074d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,7 @@ import 'ui/app_actions.dart'; import 'ui/app_drop.dart'; import 'ui/browser_page.dart'; import 'ui/downloads_page.dart'; +import 'ui/explorer_page.dart'; import 'ui/exit_dialog.dart'; import 'ui/home_page.dart'; import 'ui/player_page.dart'; @@ -283,10 +284,13 @@ Future main(List args) async { content: Text(trf('지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 ' '{0}:{1}). ' '작업 기록에 남겨 두었습니다.', [crashed.$2.hour.toString().padLeft(2, '0'), crashed.$2.minute.toString().padLeft(2, '0')])), )); } - // 시작 화면을 "웹 브라우저" 로 정했으면 브라우저를 연다 (MKV 화면은 그 아래에 있음) + // 시작 화면을 "웹 브라우저" · "파일 탐색기" 로 정했으면 그 화면을 연다 (MKV 화면은 그 아래에 있음) if (controller.settings.startScreen == 'browser') { final nav = navigatorKey.currentState; if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks)); + } else if (controller.settings.startScreen == 'files') { + final nav = navigatorKey.currentState; + if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); } await checkRequiredTools(ctx, services.shell); final ctx2 = navigatorKey.currentContext; @@ -439,6 +443,9 @@ Future runAndroid(String dataDir) async { if (controller.settings.startScreen == 'browser') { final nav = navigatorKey.currentState; if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks)); + } else if (controller.settings.startScreen == 'files') { + final nav = navigatorKey.currentState; + if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); } // 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음) final ctx = navigatorKey.currentContext; diff --git a/lib/platform/android/android_shell.dart b/lib/platform/android/android_shell.dart index 8eca0b7..114f34a 100644 --- a/lib/platform/android/android_shell.dart +++ b/lib/platform/android/android_shell.dart @@ -45,6 +45,22 @@ class AndroidShell extends NoopShell { } catch (_) {} } + /// 다른 앱으로 열기 (MainActivity "openWith"): [choose] 면 늘 앱 고르기 창, 아니면 기본 앱 (없으면 고르기 창) + @override + Future openWith(String path, {bool choose = false}) async { + try { + return await _ch.invokeMethod('openWith', {'path': path, 'choose': choose}) ?? false; + } catch (_) { + return false; + } + } + + /// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다 + @override + Future openExternal(String program, List files) async { + if (files.isNotEmpty) await openWith(files.first); + } + /// 파일이 있는 폴더 (폴더면 그 폴더) 를 파일 앱으로 @override Future revealFile(String path) async { diff --git a/lib/platform/windows/desktop_shell.dart b/lib/platform/windows/desktop_shell.dart index 26a6faa..9a935b1 100644 --- a/lib/platform/windows/desktop_shell.dart +++ b/lib/platform/windows/desktop_shell.dart @@ -291,6 +291,21 @@ class DesktopShell with WindowListener implements AppShell { Future openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser); /// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더). + /// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창 + @override + Future openWith(String path, {bool choose = false}) async { + try { + if (choose) { + await Process.start('rundll32.exe', ['shell32.dll,OpenAs_RunDLL', path], mode: ProcessStartMode.detached); + } else { + await Process.start('explorer.exe', [path], mode: ProcessStartMode.detached); + } + return true; + } catch (_) { + return false; + } + } + @override Future revealFile(String path) async { if (File(path).existsSync()) { diff --git a/lib/services/app_shell.dart b/lib/services/app_shell.dart index 3e5ee20..261bf79 100644 --- a/lib/services/app_shell.dart +++ b/lib/services/app_shell.dart @@ -57,6 +57,10 @@ abstract class AppShell { /// 파일이 있는 폴더를 파일 관리자(탐색기)로 열고 그 파일을 선택해 보여 준다 Future revealFile(String path); + /// 파일을 다른 프로그램으로 열기 (파일 탐색기). [choose]: 프로그램 고르기 창을 띄움, 아니면 기본 연결 프로그램. + /// 열지 못하면 false. + Future openWith(String path, {bool choose = false}); + /// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중) List installedBrowsers(); @@ -115,6 +119,8 @@ class NoopShell implements AppShell { @override Future revealFile(String path) async {} @override + Future openWith(String path, {bool choose = false}) async => false; + @override List installedBrowsers() => const []; @override Future> missingTools() async => const []; diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index a814ce9..27a2113 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -8,6 +8,7 @@ import '../services/app_shell.dart' show appIconButtonAsset; import '../services/system_usage.dart'; import 'browser_page.dart'; import 'downloads_page.dart'; +import 'explorer_page.dart'; import 'settings_page.dart'; import 'theme.dart'; import '../l10n/tr.dart'; @@ -38,28 +39,35 @@ class AppScope extends InheritedWidget { } /// 모든 화면의 위쪽 막대 맨 왼쪽 (같은 자리 · 같은 순서): -/// [JJ] 홈 화면 (환경 설정의 "홈 화면": MKV 화면 또는 웹 브라우저) -/// [▤] MKV 화면으로 [←] 뒤로 [⇩] 다운로드 목록 +/// [JJ] 홈 화면 (환경 설정의 "홈 화면": MKV 화면 · 웹 브라우저 · 파일 탐색기) +/// [▤] MKV 화면으로 [🌐] 웹 브라우저 [📁] 파일 탐색기 [←] 뒤로 [⇩] 다운로드 목록 class AppNavButtons extends StatelessWidget { /// 다운로드 목록 화면 자신 (그 버튼을 "지금 여기" 로 표시) final bool onDownloadsPage; /// 웹 브라우저 화면 자신 final bool onBrowserPage; - const AppNavButtons({super.key, this.onDownloadsPage = false, this.onBrowserPage = false}); - static const double width = 5 * 40; + /// 파일 탐색기 화면 자신 + final bool onExplorerPage; + const AppNavButtons({super.key, this.onDownloadsPage = false, this.onBrowserPage = false, this.onExplorerPage = false}); + + static const double width = 6 * 40; /// MKV 화면 (맨 처음 화면) 까지 돌아가기 static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst); - /// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 웹 브라우저면 브라우저를 연다 + /// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 웹 브라우저 · 파일 탐색기면 그 화면을 연다 static void toHome(BuildContext context) { final scope = AppScope.maybeOf(context); final nav = Navigator.of(context); nav.popUntil((r) => r.isFirst); - if (scope != null && scope.controller.settings.startScreen == 'browser' && scope.bookmarks != null) { + if (scope == null) return; + final start = scope.controller.settings.startScreen; + if (start == 'browser' && scope.bookmarks != null) { BrowserPage.open(nav, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!); + } else if (start == 'files') { + ExplorerPage.open(nav, c: scope.controller); } } @@ -68,7 +76,12 @@ class AppNavButtons extends StatelessWidget { final scope = AppScope.maybeOf(context); final nav = Navigator.maybeOf(context); final atRoot = !(nav?.canPop() ?? false); - final homeIsBrowser = scope?.controller.settings.startScreen == 'browser' && scope?.bookmarks != null; + final start = scope?.controller.settings.startScreen; + final homeName = start == 'browser' && scope?.bookmarks != null + ? tr('웹 브라우저') + : start == 'files' + ? tr('파일 탐색기') + : tr('MKV 화면'); final downloads = scope?.downloads; Widget btn(Widget icon, String tip, VoidCallback? f, {bool here = false}) => SizedBox( width: 40, @@ -87,7 +100,7 @@ class AppNavButtons extends StatelessWidget { btn( Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon), width: 26, height: 26, filterQuality: FilterQuality.medium), - trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeIsBrowser ? tr('웹 브라우저') : tr('MKV 화면')]), + trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]), scope == null ? null : () => toHome(context), ), btn( @@ -106,6 +119,13 @@ class AppNavButtons extends StatelessWidget { c: scope!.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!), here: onBrowserPage, ), + // 파일 탐색기: 웹 브라우저 다음 + btn( + Icon(Icons.folder_copy_outlined, color: onExplorerPage ? JjColors.accent : null), + onExplorerPage ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'), + scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller), + here: onExplorerPage, + ), btn(const Icon(Icons.arrow_back), tr('뒤로'), atRoot ? null : () => Navigator.maybePop(context)), btn( Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null), diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart new file mode 100644 index 0000000..a1742e4 --- /dev/null +++ b/lib/ui/explorer_page.dart @@ -0,0 +1,1359 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.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/file_ops.dart'; +import '../core/playlist.dart' show isVideoFile; +import '../platform/android/android_storage.dart'; +import 'app_actions.dart'; +import 'player_page.dart'; +import 'theme.dart'; +import '../l10n/tr.dart'; + +/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. +/// +/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더". +/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램), +/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …). +/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로. +/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴). +class ExplorerPage extends StatefulWidget { + final AppController c; + const ExplorerPage({super.key, required this.c}); + + static const routeName = 'explorer'; + static int _open = 0; + + /// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로) + static Future open(NavigatorState nav, {required AppController c}) async { + if (_open > 0) { + var found = false; + nav.popUntil((r) { + if (r.settings.name == routeName) found = true; + return found || r.isFirst; + }); + if (found) return; + } + await nav.push(MaterialPageRoute( + settings: const RouteSettings(name: routeName), + builder: (_) => ExplorerPage(c: c), + )); + } + + @override + State createState() => _ExplorerPageState(); +} + +/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. +enum ExplorerButton { + up(Icons.arrow_upward, '상위 폴더'), + refresh(Icons.refresh, '새로 고침'), + play(Icons.play_circle_outline, '재생'), + addMkv(Icons.playlist_add, 'MKV 목록에 추가'), + newFolder(Icons.create_new_folder_outlined, '새 폴더'), + rename(Icons.drive_file_rename_outline, '이름 변경'), + copy(Icons.copy_outlined, '복사'), + move(Icons.drive_file_move_outline, '이동'), + delete(Icons.delete_outline, '삭제'), + search(Icons.search, '찾기'), + sort(Icons.sort, '정렬 기준'), + hidden(Icons.visibility_outlined, '숨은 항목 표시'), + history(Icons.history, '내역'), + layout(Icons.view_quilt_outlined, '창 배치'), + buttons(Icons.tune, '버튼 구성'); + + final IconData icon; + final String label; + const ExplorerButton(this.icon, this.label); + + static List fromSettings(List names) { + final out = [ + for (final n in names) + for (final b in values) + if (b.name == n) b, + ]; + return out.isEmpty ? values.toList() : out; + } +} + +/// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목 +class _Pane extends ChangeNotifier { + String root; + String current; + String? focused; + final expanded = {}; + final cache = >{}; + final loading = {}; + final errors = {}; + final marked = {}; + + /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) + final scroll = ScrollController(); + + _Pane(this.root) : current = root; + + void changed() => notifyListeners(); + + @override + void dispose() { + scroll.dispose(); + super.dispose(); + } +} + +/// 트리 목록의 한 줄 +class _Row { + final FileEntry entry; + final int depth; + final bool isRoot; + const _Row(this.entry, this.depth, {this.isRoot = false}); +} + +class _ExplorerPageState extends State { + AppController get c => widget.c; + + /// 저장 장치: (경로, 이름) + List<(String, String)> _volumes = []; + late final List<_Pane> _panes = [_Pane(''), _Pane('')]; + int _active = 0; + bool _ready = false; + + @override + void initState() { + super.initState(); + ExplorerPage._open++; + for (final pane in _panes) { + pane.addListener(_onPane); + } + _init(); + } + + @override + void dispose() { + ExplorerPage._open--; + for (final pane in _panes) { + pane.dispose(); + } + super.dispose(); + } + + void _onPane() { + if (mounted) setState(() {}); + } + + Future _init() async { + _volumes = await _loadVolumes(); + if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; + final saved = c.settings.explorerPaths; + for (var i = 0; i < 2; i++) { + final want = i < saved.length && Directory(saved[i]).existsSync() ? saved[i] : _volumes[0].$1; + await _goTo(_panes[i], want, remember: false); + } + if (!mounted) return; + setState(() => _ready = true); + for (final pane in _panes) { + _reveal(pane, pane.current); // 목록이 그려진 뒤에 + } + } + + Future> _loadVolumes() async { + if (Platform.isWindows) return windowsDrives(); + if (Platform.isAndroid) { + final v = await AndroidAccess.volumes(); + return [for (final x in v) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)]; + } + return [('/', '/')]; + } + + String _volumeOf(String path) { + String best = _volumes.first.$1; + var len = -1; + for (final (v, _) in _volumes) { + final inside = samePath(v, path) || p.isWithin(v, path) || + (Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase())); + if (inside && v.length > len) { + best = v; + len = v.length; + } + } + return best; + } + + // ───────── 목록 읽기 · 이동 ───────── + + Future _load(_Pane pane, String dir, {bool force = false}) async { + if (!force && pane.cache.containsKey(dir)) return; + pane.loading.add(dir); + pane.changed(); + try { + final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden); + pane.cache[dir] = _sorted(list); + pane.errors.remove(dir); + } catch (e) { + pane.cache[dir] = const []; + pane.errors[dir] = '$e'; + } finally { + pane.loading.remove(dir); + pane.changed(); + } + } + + List _sorted(List list) { + final by = SortBy.values.firstWhere((s) => s.name == c.settings.explorerSort, orElse: () => SortBy.name); + return sortEntries(list, by, descending: c.settings.explorerSortDesc); + } + + /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다 + Future _goTo(_Pane pane, String dir, {bool remember = true}) async { + pane.root = _volumeOf(dir); + final chain = []; + var d = p.normalize(dir); + while (true) { + chain.insert(0, d); + if (samePath(d, pane.root) || p.dirname(d) == d) break; + d = p.dirname(d); + } + for (final x in chain) { + pane.expanded.add(x); + await _load(pane, x); + } + pane + ..current = dir + ..focused = dir; + pane.changed(); + _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)); + if (i < 0) return; + final pos = pane.scroll.position; + final want = (i * _rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent); + pane.scroll.jumpTo(want); + }); + } + + /// 마지막 폴더 · 내역 저장 + void _remember(_Pane pane, String dir) { + final i = _panes.indexOf(pane); + final paths = [..._panes.map((x) => x.current)]; + final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList(); + unawaited(c.updateSettings((s) => s + ..explorerPaths = paths + ..explorerHistory = hist)); + if (i >= 0) _active = i; + } + + List<_Row> _rows(_Pane pane) { + 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 []) { + out.add(_Row(e, depth)); + if (e.isDir) walk(e.path, depth + 1); + } + } + + walk(pane.root, 1); + return out; + } + + // ───────── 누르기 ───────── + + Future _tap(_Pane pane, FileEntry e) async { + 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); + } + return; + } + pane.current = p.dirname(e.path); + pane.changed(); + await _open(e.path); + } + + /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 + Future _open(String path) async { + if (isVideoFile(path)) { + await playFiles(context, c, [path]); + return; + } + final ok = await c.services.shell.openWith(path); + if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); + } + + Future _menu(_Pane pane, FileEntry e, Offset at) async { + setState(() => _active = _panes.indexOf(pane)); + pane.focused = e.path; + pane.changed(); + final video = !e.isDir && isVideoFile(e.path); + final dual = c.settings.explorerLayout == 'dual'; + final items = <(String, IconData, String)>[ + if (e.isDir) ('open', Icons.folder_open, tr('열기')), + if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), + if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), + if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), + if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), + if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), + if (video) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), + ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), + if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), + if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), + ('delete', Icons.delete_outline, tr('삭제')), + if (!Platform.isAndroid || e.isDir) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), + ('info', Icons.info_outline, tr('정보')), + ]; + final size = MediaQuery.sizeOf(context); + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), + items: [ + for (final (id, icon, label) in items) + PopupMenuItem( + value: id, + child: Row(children: [Icon(icon, size: 18), const SizedBox(width: 12), Text(label)]), + ), + ], + ); + if (pick == null || !mounted) return; + switch (pick) { + case 'open': + await _goTo(pane, e.path); + case 'openOther': + final other = _panes[1 - _panes.indexOf(pane)]; + await _goTo(other, e.path); + setState(() => _active = _panes.indexOf(other)); + case 'playFolder': + await _playFolder(e.path); + case 'playInternal': + await playFiles(context, c, [e.path], internal: true); + case 'openWith': + if (!await c.services.shell.openWith(e.path, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); + case 'openDefault': + if (!await c.services.shell.openWith(e.path)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); + case 'addMkv': + await _addMkv([e.path]); + case 'rename': + await _rename(pane, e.path); + case 'copy': + await _transfer(pane, [e.path], move: false); + case 'move': + await _transfer(pane, [e.path], move: true); + case 'delete': + await _delete(pane, [e.path]); + case 'reveal': + await c.services.shell.revealFile(e.path); + case 'info': + await _info(e); + } + } + + // ───────── 기능 ───────── + + _Pane get _pane => _panes[_active]; + _Pane? get _other => c.settings.explorerLayout == 'dual' ? _panes[1 - _active] : null; + + /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) + List _targets(_Pane pane) { + if (pane.marked.isNotEmpty) return pane.marked.toList(); + final f = pane.focused; + if (f == null || samePath(f, pane.root)) return const []; + return [f]; + } + + void _snack(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + } + + Future _refresh(_Pane pane, Iterable dirs) async { + for (final d in {...dirs}) { + if (pane.cache.containsKey(d)) await _load(pane, d, force: true); + } + } + + /// 바뀐 폴더를 두 창 모두 다시 읽는다 + Future _refreshAll(Iterable dirs) async { + for (final pane in _panes) { + await _refresh(pane, dirs); + } + } + + Future _button(ExplorerButton b) async { + final pane = _pane; + switch (b) { + case ExplorerButton.up: + if (samePath(pane.current, pane.root)) return; + final up = p.dirname(pane.current); + pane.expanded.remove(pane.current); + await _goTo(pane, up); + case ExplorerButton.refresh: + pane.cache.clear(); + for (final d in pane.expanded.toList()) { + await _load(pane, d, force: true); + } + case ExplorerButton.play: + final t = _targets(pane); + final videos = t.where((x) => !FileSystemEntity.isDirectorySync(x) && isVideoFile(x)).toList(); + if (videos.isNotEmpty) { + await playFiles(context, c, videos, keepOrder: pane.marked.isNotEmpty); + } else { + await _playFolder(t.length == 1 && FileSystemEntity.isDirectorySync(t.first) ? t.first : pane.current); + } + case ExplorerButton.addMkv: + final t = _targets(pane); + await _addMkv(t.isEmpty ? [pane.current] : t); + case ExplorerButton.newFolder: + final name = await _askName(tr('새 폴더'), ''); + if (name == null) return; + try { + final made = await FileOps.makeFolder(pane.current, name); + await _refreshAll([pane.current]); + pane.focused = made; + pane.changed(); + } catch (e) { + _snack(trf('폴더를 만들 수 없습니다: {0}', [e])); + } + case ExplorerButton.rename: + final t = _targets(pane); + if (t.length != 1) { + _snack(tr('이름을 바꿀 항목 하나를 고르세요.')); + return; + } + await _rename(pane, t.first); + case ExplorerButton.copy: + await _transfer(pane, _targets(pane), move: false); + case ExplorerButton.move: + await _transfer(pane, _targets(pane), move: true); + case ExplorerButton.delete: + await _delete(pane, _targets(pane)); + case ExplorerButton.search: + await _search(pane); + case ExplorerButton.sort: + await _sortDialog(); + case ExplorerButton.hidden: + await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); + for (final x in _panes) { + x.cache.clear(); + for (final d in x.expanded.toList()) { + await _load(x, d, force: true); + } + } + _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); + case ExplorerButton.history: + await _historyDialog(pane); + case ExplorerButton.layout: + await _layoutDialog(); + case ExplorerButton.buttons: + await _buttonsDialog(); + } + } + + Future _playFolder(String dir) async { + final list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); + if (list.isEmpty) { + _snack(tr('이 폴더에 동영상이 없습니다.')); + return; + } + if (!mounted) return; + await playFiles(context, c, list); + } + + Future _addMkv(List paths) async { + final videos = []; + for (final x in paths) { + if (FileSystemEntity.isDirectorySync(x)) { + videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path)); + } else if (isVideoFile(x)) { + videos.add(x); + } + } + if (videos.isEmpty) { + _snack(tr('추가할 동영상이 없습니다.')); + return; + } + await c.addVideos(videos, allowOutputFolder: true); + _snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); + } + + Future _rename(_Pane pane, String path) async { + final name = await _askName(tr('이름 변경'), p.basename(path)); + if (name == null || name == p.basename(path)) return; + try { + final now = await FileOps.rename(path, name); + await _refreshAll([p.dirname(path)]); + for (final x in _panes) { + if (x.marked.remove(path)) x.marked.add(now); + } + pane.focused = now; + pane.changed(); + } catch (e) { + _snack(trf('이름을 바꿀 수 없습니다: {0}', [e])); + } + } + + Future _delete(_Pane pane, List paths) async { + if (paths.isEmpty) { + _snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); + return; + } + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('삭제')), + // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) + content: Text(paths.length == 1 + ? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [p.basename(paths.first)])}\n${p.dirname(paths.first)}' + : [ + trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]), + for (final x in paths.take(8)) '· ${p.basename(x)}', + if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), + {for (final x in paths) p.dirname(x)}.join('\n'), + ].join('\n')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + onPressed: () => Navigator.pop(ctx, true), + child: Text(tr('삭제')), + ), + ], + ), + ); + if (ok != true) return; + try { + await FileOps().delete(paths); + } catch (e) { + _snack(trf('지우지 못했습니다: {0}', [e])); + } + for (final x in _panes) { + x.marked.removeAll(paths); + x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); + if (paths.any((s) => isSameOrInside(x.current, s))) x.current = p.dirname(paths.first); + } + await _refreshAll(paths.map(p.dirname)); + } + + /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) + Future _transfer(_Pane pane, List sources, {required bool move}) async { + final other = _other; + if (other == null) { + _snack(tr('복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).')); + return; + } + if (sources.isEmpty) { + _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); + return; + } + final dest = other.current; + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(move ? tr('이동') : tr('복사')), + content: Text(trf('{0}개 항목을 다음 폴더로 {1}:\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), + ], + ), + ); + if (go != true || !mounted) return; + final ops = FileOps(); + final progress = ValueNotifier<(String, double)>(('', 0)); + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(move ? tr('옮기는 중') : tr('복사하는 중')), + content: ValueListenableBuilder<(String, double)>( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(p.basename(v.$1), maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 10), + LinearProgressIndicator(value: v.$2 <= 0 ? null : v.$2), + const SizedBox(height: 6), + Text('${(v.$2 * 100).round()}%', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + ), + actions: [TextButton(onPressed: ops.cancel, child: Text(tr('취소')))], + ), + ); + Object? error; + var made = []; + try { + void onP(String f, int done, int total) => progress.value = (f, total == 0 ? 0 : done / total); + made = move ? await ops.move(sources, dest, onProgress: onP) : await ops.copy(sources, dest, onProgress: onP); + } on FileOpCancelled { + error = tr('취소했습니다.'); + } catch (e) { + error = e; + } + if (mounted) Navigator.of(context).pop(); + pane.marked.clear(); + await _refreshAll([dest, ...sources.map(p.dirname)]); + other.expanded.add(dest); + other.changed(); + _snack(error != null + ? trf('끝나지 못했습니다: {0}', [error]) + : trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); + } + + Future _askName(String title, String initial) { + final ctl = TextEditingController(text: initial); + final dot = initial.lastIndexOf('.'); + ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 ? dot : initial.length); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(title), + content: TextField( + controller: ctl, + autofocus: true, + decoration: InputDecoration(hintText: tr('이름')), + onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + FilledButton( + onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()), + child: Text(tr('확인'))), + ], + ), + ); + } + + Future _info(FileEntry e) async { + final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; + final meta = !e.isDir && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(e.name), + content: SelectableText([ + e.path, + '${tr('크기')}: ${formatSize(size)}', + if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', + if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', + ].join('\n')), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], + ), + ); + } + + Future _search(_Pane pane) async { + final q = await _askName(trf('찾기: {0}', [pane.current]), ''); + if (q == null || !mounted) return; + final found = await showDialog( + context: context, + builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden), + ); + if (found == null) return; + await _goTo(pane, found.isDir ? found.path : p.dirname(found.path)); + pane.focused = found.path; + pane.changed(); + } + + Future _sortDialog() async { + final s = c.settings; + var by = s.explorerSort; + var desc = s.explorerSortDesc; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('정렬 기준')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Wrap(spacing: 6, children: [ + for (final (v, label) in [ + ('name', tr('이름')), + ('date', tr('바뀐 날')), + ('size', tr('크기')), + ('type', tr('종류 (확장자)')), + ]) + ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)), + ]), + SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ), + ), + ); + if (ok != true) return; + await c.updateSettings((x) => x + ..explorerSort = by + ..explorerSortDesc = desc); + for (final pane in _panes) { + for (final k in pane.cache.keys.toList()) { + pane.cache[k] = _sorted(pane.cache[k]!); + } + pane.changed(); + } + } + + Future _historyDialog(_Pane pane) async { + final hist = c.settings.explorerHistory.where((h) => Directory(h).existsSync()).toList(); + final pick = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(tr('내역 (최근 폴더)')), + children: [ + if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))), + for (final h in hist) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, h), + child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + if (pick != null) await _goTo(pane, pick); + } + + Future _layoutDialog() async { + final s = c.settings; + var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + Widget group(String title, String value, List<(String, String)> items, void Function(String) on) => + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 8, left: 4), + child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Wrap(spacing: 6, children: [ + for (final (v, label) in items) + ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))), + ]), + ]); + return AlertDialog( + title: Text(tr('창 배치')), + 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('두 창 배치'), orient, [ + ('auto', tr('화면 모양 따라')), + ('side', tr('좌우')), + ('stacked', tr('위아래')), + ], (v) => orient = v), + group(tr('기능 버튼 줄'), bar, [ + ('middle', layout == 'dual' ? tr('두 창 사이') : tr('왼쪽')), + ('edge', tr('끝 (오른쪽 · 아래)')), + ('hidden', tr('숨김')), + ], (v) => bar = v), + if (bar == 'hidden') + Padding( + padding: const EdgeInsets.only(top: 8, left: 4), + child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ); + }, + ), + ); + if (ok != true) return; + await c.updateSettings((x) => x + ..explorerLayout = layout + ..explorerOrientation = orient + ..explorerToolbar = bar); + if (layout == 'single') setState(() => _active = 0); + } + + Future _buttonsDialog() async { + final current = ExplorerButton.fromSettings(c.settings.explorerButtons); + final order = [...current, ...ExplorerButton.values.where((b) => !current.contains(b))]; + final on = {...current}; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('버튼 구성')), + content: SizedBox( + width: 380, + height: 460, + child: Column(children: [ + Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + Expanded( + child: ReorderableListView( + buildDefaultDragHandles: true, + onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))), + children: [ + for (final b in order) + CheckboxListTile( + key: ValueKey(b), + dense: true, + value: on.contains(b), + secondary: Icon(b.icon), + title: Text(tr(b.label)), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)), + ), + ], + ), + ), + ]), + ), + actions: [ + TextButton( + onPressed: () => set(() { + order + ..clear() + ..addAll(ExplorerButton.values); + on + ..clear() + ..addAll(ExplorerButton.values); + }), + child: Text(tr('기본값')), + ), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ), + ), + ); + if (ok != true) return; + final names = [for (final b in order) if (on.contains(b)) b.name]; + await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names); + } + + // ───────── 화면 ───────── + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), + const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete), + const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), + const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), + const SingleActivator(LogicalKeyboardKey.tab): () => + setState(() => _active = c.settings.explorerLayout == 'dual' ? 1 - _active : 0), + }, + child: Focus( + autofocus: true, + child: Scaffold( + body: ListenableBuilder( + listenable: c, + builder: (context, _) => Column(children: [ + _topBar(), + Expanded(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), + ]), + ), + ), + ), + ); + } + + Widget _topBar() => Container( + height: appBarHeight, + color: JjColors.panel, + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), + child: Row(children: [ + const AppNavButtons(onExplorerPage: true), + const SizedBox(width: 8), + Text(tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + Expanded( + child: Text(_ready ? _pane.current : '', + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + ), + PopupMenuButton( + tooltip: tr('창 배치 · 버튼 구성'), + icon: const Icon(Icons.more_vert), + onSelected: _button, + itemBuilder: (_) => [ + for (final b in [ExplorerButton.layout, ExplorerButton.buttons, ExplorerButton.sort, ExplorerButton.hidden]) + PopupMenuItem( + value: b, + child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]), + ), + ], + ), + AppActions(c: c), + ]), + ); + + Widget _body() { + final s = c.settings; + final dual = s.explorerLayout == 'dual'; + 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 children = [ + if (dual) ...[ + pane(0), + if (bar != null && s.explorerToolbar == 'middle') bar, + pane(1), + if (bar != null && s.explorerToolbar == 'edge') bar, + ] else ...[ + if (bar != null && s.explorerToolbar == 'middle') bar, + pane(0), + if (bar != null && s.explorerToolbar == 'edge') bar, + ], + ]; + return side ? Row(children: children) : Column(children: children); + }); + } + + Widget _paneView(int i) { + final pane = _panes[i]; + final active = i == _active; + final rows = _rows(pane); + return GestureDetector( + onTapDown: (_) { + if (_active != i) setState(() => _active = i); + }, + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: JjColors.bg, + 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('표시 지우기'))), + ]), + ), + ]), + ), + ); + } + + /// 창 위쪽: 저장 장치 고르기 + Widget _paneHeader(_Pane pane) => SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + children: [ + for (final (path, label) in _volumes) + Padding( + padding: const EdgeInsets.only(right: 4), + child: TextButton.icon( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null, + foregroundColor: samePath(pane.root, path) ? JjColors.accent : null, + ), + icon: Icon(Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16), + label: Text(label), + onPressed: () { + setState(() => _active = _panes.indexOf(pane)); + _goTo(pane, path); + }, + ), + ), + ], + ), + ); + + Widget _rowView(_Pane pane, _Row row) { + final e = row.entry; + final isCurrent = e.isDir && samePath(pane.current, e.path); + final isFocused = 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 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; + return GestureDetector( + onSecondaryTapUp: (d) => _menu(pane, e, d.globalPosition), + onLongPressStart: (d) => _menu(pane, e, d.globalPosition), + 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, + ), + 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); + }), + ), + ]), + ), + ), + ), + ); + } + + 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)), + ); + } + 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); + } +} + +String _date(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' + '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + +/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) +class _Toolbar extends StatelessWidget { + final List buttons; + final bool vertical; + final void Function(ExplorerButton) onPressed; + final bool Function(ExplorerButton) enabled; + final bool Function(ExplorerButton) selected; + const _Toolbar( + {required this.buttons, required this.vertical, required this.onPressed, required this.enabled, required this.selected}); + + @override + Widget build(BuildContext context) { + Widget btn(ExplorerButton b) { + final on = enabled(b); + final sel = selected(b); + return Tooltip( + message: tr(b.label), + child: InkWell( + onTap: on ? () => onPressed(b) : null, + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 72, + height: 58, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(b.icon, size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), + const SizedBox(height: 2), + Text(tr(b.label), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), + ]), + ), + ), + ); + } + + return Container( + color: JjColors.panel, + width: vertical ? 76 : null, + height: vertical ? null : 62, + child: SingleChildScrollView( + scrollDirection: vertical ? Axis.vertical : Axis.horizontal, + padding: const EdgeInsets.all(2), + child: vertical + ? Column(children: [for (final b in buttons) btn(b)]) + : Row(children: [for (final b in buttons) btn(b)]), + ), + ); + } +} + +/// 찾기 결과 (찾는 대로 보여 주고, 누르면 그 자리로) +class _SearchDialog extends StatefulWidget { + final String root; + final String query; + final bool showHidden; + const _SearchDialog({required this.root, required this.query, required this.showHidden}); + + @override + State<_SearchDialog> createState() => _SearchDialogState(); +} + +class _SearchDialogState extends State<_SearchDialog> { + final _found = []; + StreamSubscription? _sub; + bool _done = false; + + @override + void initState() { + super.initState(); + _sub = searchFiles(widget.root, widget.query, showHidden: widget.showHidden).listen( + (e) => setState(() => _found.add(e)), + onDone: () => setState(() => _done = true), + onError: (_) {}, + ); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Row(children: [ + Expanded(child: Text(trf('"{0}" 찾기 · {1}개', [widget.query, _found.length]))), + if (!_done) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + ]), + content: SizedBox( + width: 560, + height: 420, + child: _found.isEmpty && _done + ? Center(child: Text(tr('찾은 것이 없습니다.'))) + : ListView.builder( + itemCount: _found.length, + itemBuilder: (_, i) { + final e = _found[i]; + return ListTile( + dense: true, + leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, + color: e.isDir ? Colors.amber : null), + title: Text(e.name), + subtitle: Text(p.dirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => Navigator.pop(context, e), + ); + }, + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기')))], + ); +} + +// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── + +/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) +class _Queue { + final int max; + int _running = 0; + final _waiting = Queue>(); + _Queue(this.max); + + Future run(Future Function() job) async { + if (_running >= max) { + final w = Completer(); + _waiting.add(w); + await w.future; + } + _running++; + try { + return await job(); + } finally { + _running--; + if (_waiting.isNotEmpty) _waiting.removeFirst().complete(); + } + } +} + +/// "1920×1080 · 1:23:45" +class _Meta { + static final _cache = >{}; + static final _q = _Queue(2); + + static Future of(AppController c, String path) => _cache[path] ??= _q.run(() async { + try { + final info = await c.services.mediaTool.probe(path); + final v = info.ofType('video').where((s) => (s.width ?? 0) > 0).firstOrNull; + final d = info.duration; + return [ + if (v != null) '${v.width}×${v.height}', + if (d != null) _dur(d), + ].join(' · '); + } catch (_) { + return ''; + } + }); + + static String _dur(Duration d) { + final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60; + String two(int x) => x.toString().padLeft(2, '0'); + return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}'; + } +} + +class _MetaText extends StatelessWidget { + final AppController c; + final String path; + const _MetaText({required this.c, required this.path}); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _Meta.of(c, path), + builder: (_, s) => Text(s.data ?? '', + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: JjColors.accent)), + ); +} + +/// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀). +class _Thumbs { + static final _cache = >{}; + static final _q = _Queue(1); + + static Future of(AppController c, FileEntry e) { + final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}'; + return _cache[key] ??= _q.run(() async { + try { + final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs')); + await dir.create(recursive: true); + final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); + if (await out.exists() && await out.length() > 0) return out; + if (c.busy) { + _cache.remove(key); // 작업이 끝나면 다시 시도 + return null; + } + for (final at in ['5', '0']) { + await c.services.mediaTool.runFfmpeg([ + '-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, // + '-frames:v', '1', '-vf', 'scale=160:-2', '-y', out.path, + ]); + if (await out.exists() && await out.length() > 0) return out; + } + } catch (_) {} + return null; + }); + } +} + +class _Thumb extends StatelessWidget { + final AppController c; + final FileEntry entry; + const _Thumb({required this.c, required this.entry}); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _Thumbs.of(c, entry), + builder: (_, s) { + final f = s.data; + if (f == null) return const Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Stack(alignment: Alignment.center, children: [ + Image.file(f, width: 56, height: 44, fit: BoxFit.cover, + errorBuilder: (_, _, _) => const Icon(Icons.movie_outlined, size: 28)), + const Icon(Icons.play_arrow, size: 18, color: Colors.white70), + ]), + ); + }, + ); +} diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 50bacac..e00b27f 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -15,11 +15,11 @@ import 'app_actions.dart'; import 'theme.dart'; import '../l10n/tr.dart'; -/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로) +/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어) Future playFiles(BuildContext context, AppController c, List files, - {bool keepOrder = false}) async { + {bool keepOrder = false, bool internal = false}) async { final create = c.services.createMediaPlayer; - final plan = await c.preparePlayback(files, keepOrder: keepOrder); + final plan = await c.preparePlayback(files, keepOrder: keepOrder, internal: internal); if (plan == null || create == null || !context.mounted) return; final (list, start) = plan; await Navigator.of(context).push(MaterialPageRoute( diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index b1c8213..b7837c0 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -308,6 +308,7 @@ class _SettingsPageState extends State { items: [ DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))), DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))), + DropdownMenuItem(value: 'files', child: Text(tr('파일 탐색기'))), ], onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), ), diff --git a/test/batch_test.dart b/test/batch_test.dart index f0765d1..2370f1a 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -189,7 +189,7 @@ void main() { }); testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { - tester.view.physicalSize = const Size(1500, 900); + tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices( diff --git a/test/explorer_test.dart b/test/explorer_test.dart new file mode 100644 index 0000000..d47e512 --- /dev/null +++ b/test/explorer_test.dart @@ -0,0 +1,178 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/app_shell.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; + +/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) +class _Shell extends NoopShell { + final opened = <(String, bool)>[]; + @override + Future openWith(String path, {bool choose = false}) async { + opened.add((path, choose)); + return true; + } +} + +void main() { + late Directory tmp; + late String left, right; + late _Shell shell; + late AppController c; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_explorer_'); + left = p.join(tmp.path, 'left'); + right = p.join(tmp.path, 'right'); + File(p.join(left, 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + File(p.join(left, 'sub', 'inner.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('inner'); + Directory(right).createSync(); + shell = _Shell(); + c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); + c.settings.explorerPaths = [left, right]; + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future open(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + } + + 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, () => 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.last, (p.join(left, 'doc.txt'), false)); + + // 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창) + await tester.longPress(find.text('doc.txt')); + await tester.pumpAndSettle(); + expect(find.text('다른 창으로 복사'), findsOneWidget); + 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 act(tester, () => tester.tap(find.text('복사').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + }); + + 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(); + // 지울 항목 이름이 확인 창에 보인다 + expect(find.textContaining('2개 항목을 지울까요?'), findsOneWidget); + expect(find.textContaining('· sub'), findsOneWidget); + expect(find.textContaining('· doc.txt'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '삭제'))); + await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); + expect(Directory(left).listSync(), isEmpty); + + // 새 폴더 → 이름 변경 + await act(tester, () => tester.tap(find.text('새 폴더').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.enterText(find.byType(TextField), 'films')); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.text('films').evaluate().isNotEmpty); + expect(Directory(p.join(left, 'films')).existsSync(), isTrue); + await act(tester, () => tester.tap(find.text('이름 변경').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.enterText(find.byType(TextField), 'movies')); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.text('movies').evaluate().isNotEmpty); + expect(Directory(p.join(left, 'movies')).existsSync(), isTrue); + }); + + testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async { + await open(tester); + await tester.tap(find.text('창 배치').first); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ChoiceChip, '한 창')); + await tester.pump(); + await tester.tap(find.widgetWithText(ChoiceChip, '숨김')); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await tester.pumpAndSettle(); + expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']); + expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김 + expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만) + + // ⋮ 메뉴에서 버튼 구성: "찾기" 빼기 + await c.updateSettings((s) => s.explorerToolbar = 'middle'); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); + await tester.pumpAndSettle(); + await tester.tap(find.text('버튼 구성').last); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await tester.pumpAndSettle(); + expect(c.settings.explorerButtons, isNot(contains('addMkv'))); + expect(c.settings.explorerButtons, contains('copy')); + expect(find.text('MKV 목록에 추가'), findsNothing); + + // 설정 저장 · 읽기 + final back = AppSettings.fromJson(c.settings.toJson()); + expect([back.explorerLayout, back.explorerToolbar, back.explorerButtons], + [c.settings.explorerLayout, c.settings.explorerToolbar, c.settings.explorerButtons]); + }); +} + +/// [name] 줄의 "표시" 동그라미 버튼 +Finder markOf(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, + matching: find.byTooltip('표시 (여러 개 고르기)'), + ); + +/// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) +Future act(WidgetTester tester, Future Function() f) async { + await tester.runAsync(f); + await tester.pump(); +} + +/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신 +Future settle(WidgetTester tester, bool Function() done) async { + for (var i = 0; i < 100; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + if (done()) break; + } + await tester.pump(const Duration(milliseconds: 300)); +} diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart new file mode 100644 index 0000000..625d08b --- /dev/null +++ b/test/file_ops_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_fileops_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + File file(String rel, [String text = 'x']) => + File(p.join(tmp.path, rel)) + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + + test('폴더 읽기: 숨은 항목 · 정렬 (폴더 먼저, 숫자는 크기로, 크기 · 거꾸로)', () async { + file('a/ep10.mkv', '1234567890'); + file('a/ep2.mkv', '12'); + file('a/.hidden'); + Directory(p.join(tmp.path, 'a', 'zfolder')).createSync(); + final dir = p.join(tmp.path, 'a'); + + final list = await listEntries(dir); + expect(list.map((e) => e.name), isNot(contains('.hidden'))); + expect((await listEntries(dir, showHidden: true)).map((e) => e.name), contains('.hidden')); + + expect(sortEntries(list, SortBy.name).map((e) => e.name), ['zfolder', 'ep2.mkv', 'ep10.mkv']); + // 거꾸로여도 폴더는 위 + expect(sortEntries(list, SortBy.size, descending: true).map((e) => e.name), ['zfolder', 'ep10.mkv', 'ep2.mkv']); + final e = list.firstWhere((x) => x.name == 'ep10.mkv'); + expect([e.ext, e.size, e.isDir], ['mkv', 10, false]); + }); + + test('복사: 폴더째 · 같은 이름은 "이름 (2)" · 진행률 · 자기 안으로는 안 됨', () async { + file('src/movie.mp4', 'abcdef'); + file('src/sub/s.srt', 'sub'); + file('dst/movie.mp4', 'old'); + final dst = p.join(tmp.path, 'dst'); + var last = 0; + final made = await FileOps().copy( + [p.join(tmp.path, 'src', 'movie.mp4'), p.join(tmp.path, 'src', 'sub')], dst, + onProgress: (_, done, total) { + last = done; + expect(total, 9); + }); + expect(made.map(p.basename), ['movie (2).mp4', 'sub']); + expect(File(p.join(dst, 'movie (2).mp4')).readAsStringSync(), 'abcdef'); + expect(File(p.join(dst, 'movie.mp4')).readAsStringSync(), 'old'); + expect(File(p.join(dst, 'sub', 's.srt')).readAsStringSync(), 'sub'); + expect(last, 9); + expect(() => FileOps().copy([p.join(tmp.path, 'src')], p.join(tmp.path, 'src', 'sub')), + throwsA(isA())); + }); + + test('이동 · 삭제 · 이름 바꾸기 · 새 폴더', () async { + final f = file('a/x.mkv', 'video'); + final b = Directory(p.join(tmp.path, 'b'))..createSync(); + final moved = await FileOps().move([f.path], b.path); + expect(File(moved.single).readAsStringSync(), 'video'); + expect(f.existsSync(), isFalse); + // 이미 그 폴더에 있으면 그대로 + expect(await FileOps().move([moved.single], b.path), [moved.single]); + + final renamed = await FileOps.rename(moved.single, 'y.mkv'); + expect(p.basename(renamed), 'y.mkv'); + expect(() => FileOps.rename(renamed, 'bad/name'), throwsA(isA())); + file('b/z.mkv'); + expect(() => FileOps.rename(renamed, 'z.mkv'), throwsA(isA())); + + final folder = await FileOps.makeFolder(b.path, '새 폴더'); + expect(Directory(folder).existsSync(), isTrue); + expect(() => FileOps.makeFolder(b.path, '새 폴더'), throwsA(isA())); + + await FileOps().delete([b.path]); + expect(b.existsSync(), isFalse); + }); + + test('복사 취소: 만들던 파일을 지우고 FileOpCancelled', () async { + final big = file('big.bin', 'x' * (2 * 1024 * 1024)); + final dst = Directory(p.join(tmp.path, 'dst'))..createSync(); + final ops = FileOps(); + final f = ops.copy([big.path], dst.path, onProgress: (_, done, _) { + if (done > 0) ops.cancel(); + }); + await expectLater(f, throwsA(isA())); + expect(dst.listSync(), isEmpty); + }); + + test('찾기: 하위 폴더까지 · 대소문자 무시 · * 사용', () async { + file('a/Movie One.mkv'); + file('a/b/movie two.mp4'); + file('a/b/notes.txt'); + final all = await searchFiles(tmp.path, 'MOVIE').map((e) => e.name).toList(); + expect(all.toSet(), {'Movie One.mkv', 'movie two.mp4'}); + expect(await searchFiles(tmp.path, '*.txt').map((e) => e.name).toList(), ['notes.txt']); + expect(await searchFiles(tmp.path, '').toList(), isEmpty); + }); + + test('같은 경로 · 안쪽 판단, 크기 글자', () { + final a = p.join(tmp.path, 'a'); + expect(samePath(a, '$a${Platform.pathSeparator}'), isTrue); + expect(isSameOrInside(p.join(a, 'b'), a), isTrue); + expect(isSameOrInside(a, p.join(a, 'b')), isFalse); + expect(formatSize(500), '500B'); + expect(formatSize(1536), '2KB'); + expect(formatSize(5 * 1024 * 1024), '5.0MB'); + expect(formatSize(3 * 1024 * 1024 * 1024), '3.0GB'); + }); +}