파일 탐색기 (X-plore 참고): 두 창 트리 · 가운데 기능 버튼 · 창 배치 · 버튼 구성

- 위쪽 공통 버튼에 [파일 탐색기], 환경 설정 > 홈 화면에 "파일 탐색기" (앱을 켜면 바로)
- 두 창 (트리: 누르면 그 자리에서 펼치기 · 접기), 저장 장치 고르기 (Windows 드라이브 · Android 저장소)
- 동영상: 누르면 기본 동작 (내장 플레이어 또는 확장자별 재생 프로그램), 길게 누르기 · 오른쪽 클릭 메뉴
  (내장 플레이어로 재생 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 변경 · 복사 / 이동 · 삭제 · 정보)
- 썸네일 · 해상도 · 길이 (작업 중에는 썸네일을 만들지 않음), 그림 파일 미리 보기
- 가운데 버튼: 상위 폴더 · 새로 고침 · 재생 · MKV 목록에 추가 · 새 폴더 · 이름 변경 · 복사 · 이동 · 삭제 ·
  찾기 · 정렬 기준 · 숨은 항목 · 내역 · 창 배치 · 버튼 구성
- 창 배치: 두 창 / 한 창, 좌우 / 위아래 / 화면 모양 따라, 버튼 줄 두 창 사이 / 끝 / 숨김
- 버튼 구성: 보일 버튼 · 순서
- 다른 앱으로 열기: Windows "연결 프로그램" 창 · Android 앱 고르기 (FileProvider, SD 카드 포함)
- 여러 개 지울 때 확인 창에 이름 · 폴더를 보여 준다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 04:14:05 +09:00
parent bba955f6bf
commit 99024d3c6e
20 changed files with 2363 additions and 17 deletions

View File

@ -64,6 +64,9 @@ class MainActivity : FlutterActivity() {
result.error("INSTALL", e.message ?: e.toString(), null)
}
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
"openWith" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
)
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("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

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 업데이트 APK (MainActivity.installApk) -->
<!-- 업데이트 APK (MainActivity.installApk) · 파일 탐색기에서 다른 앱으로 열기 (MainActivity.openWith, SD 카드 포함) -->
<paths>
<cache-path name="updates" path="updates/"/>
<root-path name="root" path="."/>
</paths>

View File

@ -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",

View File

@ -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 @@
"화면": "画面",
"화면 · 색 보정": "画面・カラー補正",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。",
"화면 모양 따라": "画面の形に合わせる",
"화면 방향": "画面の向き",
"화면 비율": "画面比率",
"화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比率(横・縦)・回転・明るさ・コントラスト・彩度・色温度",

View File

@ -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 @@
"화면": "显示",
"화면 · 색 보정": "画面 · 色彩",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。",
"화면 모양 따라": "跟随屏幕形状",
"화면 방향": "屏幕方向",
"화면 비율": "画面比例",
"화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比例(横屏 · 竖屏)· 旋转 · 亮度 · 对比度 · 饱和度 · 色温",

View File

@ -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<void>.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();
});
}

View File

@ -1114,12 +1114,14 @@ class AppController extends ChangeNotifier {
/// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null,
/// 아니면 내장 플레이어용 (목록, 시작 위치).
/// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생
Future<(List<String>, int)?> preparePlayback(List<String> files, {bool keepOrder = false}) async {
/// [internal]: 외부 프로그램 설정과 상관없이 내장 플레이어로 (파일 탐색기의 "내장 플레이어로 재생")
Future<(List<String>, int)?> preparePlayback(List<String> 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;

View File

@ -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<String> explorerButtons = [];
/// 정렬 · 숨긴 항목
String explorerSort = 'name';
bool explorerSortDesc = false;
bool explorerShowHidden = false;
/// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창)
List<String> explorerPaths = [];
/// 최근에 연 폴더 (내역, 최신이 앞)
List<String> 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? ?? ''

284
lib/core/file_ops.dart Normal file
View File

@ -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<FileEntry> sortEntries(List<FileEntry> 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<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async {
final out = <FileEntry>[];
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<int> totalSize(List<String> 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<List<String>> copy(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await totalSize(sources);
var done = 0;
final made = <String>[];
Future<void> 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<void> 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<List<String>> move(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final made = <String>[];
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<void> delete(List<String> 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<String> 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<String> 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<FileEntry> 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 = <String>[root];
while (pending.isNotEmpty) {
final dir = pending.removeAt(0);
List<FileEntry> 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';
}

View File

@ -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<void> main(List<String> 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<void> 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;

View File

@ -45,6 +45,22 @@ class AndroidShell extends NoopShell {
} catch (_) {}
}
/// 다른 앱으로 열기 (MainActivity "openWith"): [choose] 면 늘 앱 고르기 창, 아니면 기본 앱 (없으면 고르기 창)
@override
Future<bool> openWith(String path, {bool choose = false}) async {
try {
return await _ch.invokeMethod<bool>('openWith', {'path': path, 'choose': choose}) ?? false;
} catch (_) {
return false;
}
}
/// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다
@override
Future<void> openExternal(String program, List<String> files) async {
if (files.isNotEmpty) await openWith(files.first);
}
/// 파일이 있는 폴더 (폴더면 그 폴더) 를 파일 앱으로
@override
Future<void> revealFile(String path) async {

View File

@ -291,6 +291,21 @@ class DesktopShell with WindowListener implements AppShell {
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
/// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더).
/// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창
@override
Future<bool> 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<void> revealFile(String path) async {
if (File(path).existsSync()) {

View File

@ -57,6 +57,10 @@ abstract class AppShell {
/// 파일이 있는 폴더를 파일 관리자(탐색기)로 열고 그 파일을 선택해 보여 준다
Future<void> revealFile(String path);
/// 파일을 다른 프로그램으로 열기 (파일 탐색기). [choose]: 프로그램 고르기 창을 띄움, 아니면 기본 연결 프로그램.
/// 열지 못하면 false.
Future<bool> openWith(String path, {bool choose = false});
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers();
@ -115,6 +119,8 @@ class NoopShell implements AppShell {
@override
Future<void> revealFile(String path) async {}
@override
Future<bool> openWith(String path, {bool choose = false}) async => false;
@override
List<String> installedBrowsers() => const [];
@override
Future<List<String>> missingTools() async => const [];

View File

@ -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),

1359
lib/ui/explorer_page.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,11 +15,11 @@ import 'app_actions.dart';
import 'theme.dart';
import '../l10n/tr.dart';
/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로)
/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어)
Future<void> playFiles(BuildContext context, AppController c, List<String> 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<void>(

View File

@ -308,6 +308,7 @@ class _SettingsPageState extends State<SettingsPage> {
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!),
),

View File

@ -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(

178
test/explorer_test.dart Normal file
View File

@ -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<bool> 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<void> 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<void> act(WidgetTester tester, Future<void> Function() f) async {
await tester.runAsync(f);
await tester.pump();
}
/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신
Future<void> settle(WidgetTester tester, bool Function() done) async {
for (var i = 0; i < 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
if (done()) break;
}
await tester.pump(const Duration(milliseconds: 300));
}

110
test/file_ops_test.dart Normal file
View File

@ -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<FileSystemException>()));
});
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<FileSystemException>()));
file('b/z.mkv');
expect(() => FileOps.rename(renamed, 'z.mkv'), throwsA(isA<FileSystemException>()));
final folder = await FileOps.makeFolder(b.path, '새 폴더');
expect(Directory(folder).existsSync(), isTrue);
expect(() => FileOps.makeFolder(b.path, '새 폴더'), throwsA(isA<FileSystemException>()));
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<FileOpCancelled>()));
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');
});
}