Compare commits
10 Commits
8b64866d34
...
d46e0893e6
| Author | SHA1 | Date |
|---|---|---|
|
|
d46e0893e6 | |
|
|
7d9cfc191e | |
|
|
1cc4cb1b4b | |
|
|
e702864e5b | |
|
|
21d337d33c | |
|
|
a0019274cd | |
|
|
354798c0e7 | |
|
|
2d2bd40824 | |
|
|
5646c976a8 | |
|
|
17d5ff0be9 |
|
|
@ -179,6 +179,7 @@ class MainActivity : FlutterActivity() {
|
|||
"openWith" -> result.success(
|
||||
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
|
||||
)
|
||||
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
|
||||
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
|
||||
"keepAlive" -> {
|
||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
|
||||
|
|
@ -421,6 +422,49 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌)
|
||||
private fun shareFiles(paths: List<String>): Boolean {
|
||||
val uris = ArrayList<android.net.Uri>()
|
||||
val mimes = mutableSetOf<String>()
|
||||
for (p in paths) {
|
||||
val f = File(p)
|
||||
if (!f.isFile) continue
|
||||
uris.add(
|
||||
try {
|
||||
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
)
|
||||
mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*")
|
||||
}
|
||||
if (uris.isEmpty()) return false
|
||||
// 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */*
|
||||
val tops = mimes.map { it.substringBefore('/') }.toSet()
|
||||
val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*"
|
||||
val send = if (uris.size == 1) {
|
||||
Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
|
||||
} else {
|
||||
Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
}
|
||||
send.type = mime
|
||||
// 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다)
|
||||
val clip = android.content.ClipData.newRawUri(null, uris[0])
|
||||
for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i]))
|
||||
send.clipData = clip
|
||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
return try {
|
||||
startActivity(
|
||||
Intent.createChooser(send, null)
|
||||
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFolder(path: String): Boolean {
|
||||
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
|
||||
val root = Environment.getExternalStorageDirectory().path
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"AI 자막": "AI subtitles",
|
||||
"AI 자막 ({0}) 을 시작합니다": "Starting AI subtitles ({0})",
|
||||
"AI 자막 {0}개": "AI subtitles for {0}",
|
||||
"AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "Can be deleted after the AI subtitle / translation job finishes",
|
||||
"AI 자막 → MKV ({0}) 을 시작합니다": "Starting AI subtitles → MKV ({0})",
|
||||
"AI 자막 대기 중": "Waiting for AI subtitles",
|
||||
"AI 자막 만들기": "Make AI subtitles",
|
||||
|
|
@ -464,6 +465,8 @@
|
|||
"{0}개 · {1}": "{0} · {1}",
|
||||
"{0}개 · 주소 {1}개": "{0} · {1} links",
|
||||
"{0}개 맞춤": "{0} synced",
|
||||
"{0}개 보임": "{0} shown",
|
||||
"{0}개 삭제": "Delete {0} items",
|
||||
"{0}개 선택": "{0} selected",
|
||||
"{0}개 중 {1}개를 지우지 못했습니다": "Couldn't delete {1} of {0}",
|
||||
"{0}개 지우고 맞추기": "Delete {0} and sync",
|
||||
|
|
@ -541,6 +544,7 @@
|
|||
"가져올 즐겨찾기가 없습니다.": "No bookmarks to import.",
|
||||
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
|
||||
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
|
||||
"같은 이름이 이미 있습니다": "An item with the same name already exists",
|
||||
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
|
||||
|
|
@ -573,11 +577,15 @@
|
|||
"결과 폴더 열기: {0}": "Open output folder: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})",
|
||||
"경로": "Path",
|
||||
"경로 {0}개를 복사했습니다": "Copied {0} paths",
|
||||
"경로 복사": "Copy path",
|
||||
"경로 복사 ({0}개)": "Copy paths ({0})",
|
||||
"경로 입력": "Enter path",
|
||||
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)",
|
||||
"경로 입력 · WebDAV": "Enter path · WebDAV",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
|
||||
"경로를 복사했습니다: {0}": "Copied path: {0}",
|
||||
"계속": "Always",
|
||||
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
|
||||
"계속 받기": "Keep downloading",
|
||||
|
|
@ -602,6 +610,9 @@
|
|||
"골라서 시작": "Choose and start",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
|
||||
"공유": "Share",
|
||||
"공유 (다른 앱으로 보내기)": "Share (send to another app)",
|
||||
"공유 창을 열 수 없습니다.": "Can't open the share sheet.",
|
||||
"관리 메뉴": "Manage menu",
|
||||
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)",
|
||||
"관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "Only used when running as administrator. When off (default), the Chrome engine isn't started then and Edge is suggested.",
|
||||
|
|
@ -778,6 +789,7 @@
|
|||
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "When you go to another screen or the app goes to the background, the Tor screen's pages and connections are cleared right away. Tor itself keeps running for this long so you can use it again immediately.",
|
||||
"다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser",
|
||||
"다시 만들기 대기로": "Back to waiting for remake",
|
||||
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)",
|
||||
"다시 받기": "Download again",
|
||||
"다시 비교": "Compare again",
|
||||
"다시 설치": "Reinstall",
|
||||
|
|
@ -961,6 +973,7 @@
|
|||
"많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "High (faster): {0} - if many other apps are open, Android may close this app",
|
||||
"말레이어": "Malay",
|
||||
"망에 닿을 수 없음": "network unreachable",
|
||||
"맞는 것이 없습니다": "No matches",
|
||||
"맞추기 (검은 여백)": "Fit (black bars)",
|
||||
"맞추기 (비율이 다를 때)": "Conform (when the aspect differs)",
|
||||
"맞추기 (흐린 배경)": "Fit (blurred background)",
|
||||
|
|
@ -1060,10 +1073,12 @@
|
|||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept",
|
||||
"받다 만 AI 모델": "Unfinished AI models",
|
||||
"받다 만 다운로드": "Unfinished downloads",
|
||||
"받다 만 조각 · {0}": "Partial download · {0}",
|
||||
"받아서 열기": "Download and open",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "Downloaded AI models ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "Copy downloaded AI models ({0}) to Download/JJ_MKVMaker and restore them after reinstalling (needs {0} more space for a while)",
|
||||
"받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "Downloaded AI models (images · subtitles) - must be downloaded again (about {0})",
|
||||
"받은 AI 자막 모델": "Downloaded AI subtitle models",
|
||||
"받은 동영상을 편집 목록에 추가: {0}": "Added the received video to the edit list: {0}",
|
||||
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "Temporary files left from installing updates · required programs · the Chrome engine",
|
||||
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)",
|
||||
|
|
@ -1074,10 +1089,12 @@
|
|||
"받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}",
|
||||
"받음": "Downloaded",
|
||||
"받음 {0}": "Received {0}",
|
||||
"받음 · {0}": "Downloaded · {0}",
|
||||
"받지 못했습니다": "Could not download",
|
||||
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment",
|
||||
"받지 못했습니다. 잠시 뒤 다시 해 주세요": "Download failed. Please try again shortly",
|
||||
"받지 못했습니다: {0}": "Could not download: {0}",
|
||||
"받지 않음 (처음 쓸 때 내려받기 {0})": "Not downloaded (downloads on first use, {0})",
|
||||
"밝게": "Brighter",
|
||||
"밝기": "Brightness",
|
||||
"밤 0~6시": "Night 0-6",
|
||||
|
|
@ -1184,6 +1201,7 @@
|
|||
"빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too",
|
||||
"빠른 디코더 (TAESD)": "Fast decoder (TAESD)",
|
||||
"빠른 필터": "Quick filter",
|
||||
"빼고 싶은 것 (선택)": "What to avoid (optional)",
|
||||
"사라지는 것": "What is lost",
|
||||
"사본 만들기": "Make a copy",
|
||||
|
|
@ -1722,6 +1740,7 @@
|
|||
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
|
||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].",
|
||||
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)",
|
||||
"이어 받기": "Resume",
|
||||
"이전": "Previous",
|
||||
"이전 (P)": "Previous (P)",
|
||||
|
|
@ -1943,6 +1962,8 @@
|
|||
"즐겨찾기 추가…": "Add bookmark…",
|
||||
"즐겨찾기 추가됨": "Bookmark added",
|
||||
"즐겨찾기 표시줄": "Bookmarks bar",
|
||||
"즐겨찾기에 넣었습니다: {0}": "Added to favorites: {0}",
|
||||
"즐겨찾기에 추가": "Add to favorites",
|
||||
"즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)",
|
||||
"지금": "Current",
|
||||
"지금 {0}%": "Now {0}%",
|
||||
|
|
@ -1968,6 +1989,7 @@
|
|||
"지금 짝을 즐겨찾기에": "Add the current pair to favorites",
|
||||
"지금 폴더 추가": "Add current folder",
|
||||
"지금 폴더를 즐겨찾기에": "Add the current folder to favorites",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)",
|
||||
"지금 확인": "Check now",
|
||||
"지금: {0}": "Now: {0}",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
|
||||
|
|
@ -2160,6 +2182,7 @@
|
|||
"폴더": "Folder",
|
||||
"폴더 + 파일 목록": "Folders + file list",
|
||||
"폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)",
|
||||
"폴더 {0}개는 빼고 파일만 보냈습니다.": "Sent files only ({0} folders left out).",
|
||||
"폴더 고르기": "Choose folder",
|
||||
"폴더 만들기": "New folder",
|
||||
"폴더 복사 · 이동 방법": "Folder copy · move method",
|
||||
|
|
@ -2178,6 +2201,7 @@
|
|||
"폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "The folder does not exist or cannot be opened. On Windows, network shares use the \\\\server\\share form (converted path: {0})",
|
||||
"폴더가 없거나 열 수 없습니다: {0}": "The folder doesn't exist or can't be opened: {0}",
|
||||
"폴더가 없습니다": "The folder does not exist",
|
||||
"폴더는 공유할 수 없습니다. 파일을 고르세요.": "Folders can't be shared. Choose files.",
|
||||
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "Folders are shown, but the videos and files inside need this permission. Turn on JJ_MKVMaker on the permission screen and come back.",
|
||||
"폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
|
||||
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself",
|
||||
|
|
@ -2214,6 +2238,7 @@
|
|||
"필요한 것 모두 받기": "Download everything needed",
|
||||
"필요한 공간 {0}": "Space needed {0}",
|
||||
"필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}",
|
||||
"필터 닫기 (모두 보기)": "Close filter (show all)",
|
||||
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "To stop one at a time, press ■ on its row in the list.",
|
||||
"하위 폴더가 없습니다": "No subfolders",
|
||||
"한 번 더": "Once more",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"AI 자막": "AI 字幕",
|
||||
"AI 자막 ({0}) 을 시작합니다": "AI 字幕 ({0}) を開始します",
|
||||
"AI 자막 {0}개": "AI 字幕 {0}個",
|
||||
"AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "AI 字幕・翻訳の作業が終わってから削除できます",
|
||||
"AI 자막 → MKV ({0}) 을 시작합니다": "AI 字幕 → MKV ({0}) を開始します",
|
||||
"AI 자막 대기 중": "AI 字幕待機中",
|
||||
"AI 자막 만들기": "AI 字幕を作成",
|
||||
|
|
@ -464,6 +465,8 @@
|
|||
"{0}개 · {1}": "{0}個・{1}",
|
||||
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
|
||||
"{0}개 맞춤": "{0} 件を同期",
|
||||
"{0}개 보임": "{0} 件表示",
|
||||
"{0}개 삭제": "{0} 件を削除",
|
||||
"{0}개 선택": "{0}個選択",
|
||||
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした",
|
||||
"{0}개 지우고 맞추기": "{0} 個を削除して同期",
|
||||
|
|
@ -541,6 +544,7 @@
|
|||
"가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。",
|
||||
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
|
||||
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
|
||||
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
|
||||
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
|
||||
|
|
@ -573,11 +577,15 @@
|
|||
"결과 폴더 열기: {0}": "出力フォルダーを開く: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})",
|
||||
"경로": "パス",
|
||||
"경로 {0}개를 복사했습니다": "{0} 件のパスをコピーしました",
|
||||
"경로 복사": "パスをコピー",
|
||||
"경로 복사 ({0}개)": "パスをコピー ({0} 件)",
|
||||
"경로 입력": "パスを入力",
|
||||
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)",
|
||||
"경로 입력 · WebDAV": "パス入力 · WebDAV",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
|
||||
"경로를 복사했습니다: {0}": "パスをコピーしました: {0}",
|
||||
"계속": "常時",
|
||||
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
|
||||
"계속 받기": "ダウンロードを続ける",
|
||||
|
|
@ -602,6 +610,9 @@
|
|||
"골라서 시작": "選んで開始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
|
||||
"공유": "共有",
|
||||
"공유 (다른 앱으로 보내기)": "共有 (他のアプリに送る)",
|
||||
"공유 창을 열 수 없습니다.": "共有画面を開けません。",
|
||||
"관리 메뉴": "管理メニュー",
|
||||
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)",
|
||||
"관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。",
|
||||
|
|
@ -778,6 +789,7 @@
|
|||
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "別の画面に移るかアプリがバックグラウンドに回ると、Tor 画面のページ · 接続はすぐに片付けます。Tor 自体はこの時間だけ動かしておき、戻ったらすぐ使えます。",
|
||||
"다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める",
|
||||
"다시 만들기 대기로": "作り直し待ちに戻す",
|
||||
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)",
|
||||
"다시 받기": "再ダウンロード",
|
||||
"다시 비교": "もう一度比較",
|
||||
"다시 설치": "再インストール",
|
||||
|
|
@ -961,6 +973,7 @@
|
|||
"많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多め (速い): {0} - 他のアプリが多く開いていると Android がこのアプリを終了することがあります",
|
||||
"말레이어": "マレー語",
|
||||
"망에 닿을 수 없음": "ネットワークに到達できません",
|
||||
"맞는 것이 없습니다": "一致するものがありません",
|
||||
"맞추기 (검은 여백)": "フィット(黒い余白)",
|
||||
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
|
||||
"맞추기 (흐린 배경)": "フィット(ぼかし背景)",
|
||||
|
|
@ -1060,10 +1073,12 @@
|
|||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま",
|
||||
"받다 만 AI 모델": "ダウンロード途中の AI モデル",
|
||||
"받다 만 다운로드": "ダウンロード途中のファイル",
|
||||
"받다 만 조각 · {0}": "途中までのダウンロード · {0}",
|
||||
"받아서 열기": "ダウンロードして開く",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)",
|
||||
"받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "ダウンロードした AI モデル (画像・字幕) - 再ダウンロードが必要です (約 {0})",
|
||||
"받은 AI 자막 모델": "ダウンロード済みの AI 字幕モデル",
|
||||
"받은 동영상을 편집 목록에 추가: {0}": "受け取った動画を編集リストに追加: {0}",
|
||||
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル",
|
||||
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)",
|
||||
|
|
@ -1074,10 +1089,12 @@
|
|||
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
|
||||
"받음": "ダウンロード済み",
|
||||
"받음 {0}": "受信 {0}",
|
||||
"받음 · {0}": "ダウンロード済み · {0}",
|
||||
"받지 못했습니다": "ダウンロードできませんでした",
|
||||
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください",
|
||||
"받지 못했습니다. 잠시 뒤 다시 해 주세요": "ダウンロードできませんでした。しばらくしてからもう一度お試しください",
|
||||
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
|
||||
"받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})",
|
||||
"밝게": "明るく",
|
||||
"밝기": "明るさ",
|
||||
"밤 0~6시": "夜 0~6 時",
|
||||
|
|
@ -1184,6 +1201,7 @@
|
|||
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
|
||||
"빠른 디코더 (TAESD)": "高速デコーダー (TAESD)",
|
||||
"빠른 필터": "クイックフィルター",
|
||||
"빼고 싶은 것 (선택)": "避けたいもの (任意)",
|
||||
"사라지는 것": "消えるもの",
|
||||
"사본 만들기": "コピーを作る",
|
||||
|
|
@ -1722,6 +1740,7 @@
|
|||
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
|
||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
|
||||
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)",
|
||||
"이어 받기": "続きをダウンロード",
|
||||
"이전": "前へ",
|
||||
"이전 (P)": "前へ(P)",
|
||||
|
|
@ -1943,6 +1962,8 @@
|
|||
"즐겨찾기 추가…": "ブックマークを追加…",
|
||||
"즐겨찾기 추가됨": "ブックマークを追加しました",
|
||||
"즐겨찾기 표시줄": "ブックマークバー",
|
||||
"즐겨찾기에 넣었습니다: {0}": "お気に入りに追加しました: {0}",
|
||||
"즐겨찾기에 추가": "お気に入りに追加",
|
||||
"즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)",
|
||||
"지금": "現在",
|
||||
"지금 {0}%": "現在 {0}%",
|
||||
|
|
@ -1968,6 +1989,7 @@
|
|||
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
|
||||
"지금 폴더 추가": "今のフォルダーを追加",
|
||||
"지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)",
|
||||
"지금 확인": "今すぐ確認",
|
||||
"지금: {0}": "現在: {0}",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
|
||||
|
|
@ -2160,6 +2182,7 @@
|
|||
"폴더": "フォルダー",
|
||||
"폴더 + 파일 목록": "フォルダー + ファイル一覧",
|
||||
"폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)",
|
||||
"폴더 {0}개는 빼고 파일만 보냈습니다.": "ファイルだけ送りました (フォルダー {0} 件は除外)。",
|
||||
"폴더 고르기": "フォルダーを選択",
|
||||
"폴더 만들기": "フォルダーを作成",
|
||||
"폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法",
|
||||
|
|
@ -2178,6 +2201,7 @@
|
|||
"폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "フォルダーが存在しないか開けません。Windows のネットワーク共有は \\\\サーバー\\共有 の形式です (変換したパス: {0})",
|
||||
"폴더가 없거나 열 수 없습니다: {0}": "フォルダーがないか、開けません: {0}",
|
||||
"폴더가 없습니다": "フォルダーがありません",
|
||||
"폴더는 공유할 수 없습니다. 파일을 고르세요.": "フォルダーは共有できません。ファイルを選んでください。",
|
||||
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。",
|
||||
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
|
||||
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません",
|
||||
|
|
@ -2214,6 +2238,7 @@
|
|||
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
|
||||
"필요한 공간 {0}": "必要な容量 {0}",
|
||||
"필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}",
|
||||
"필터 닫기 (모두 보기)": "フィルターを閉じる (すべて表示)",
|
||||
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "1 つずつ止めるには、一覧の行の ■ を押してください。",
|
||||
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
||||
"한 번 더": "もう一度",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"AI 자막": "AI 字幕",
|
||||
"AI 자막 ({0}) 을 시작합니다": "开始 AI 字幕({0})",
|
||||
"AI 자막 {0}개": "AI 字幕 {0} 个",
|
||||
"AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "AI 字幕 · 翻译任务结束后才能删除",
|
||||
"AI 자막 → MKV ({0}) 을 시작합니다": "开始 AI 字幕 → MKV({0})",
|
||||
"AI 자막 대기 중": "等待 AI 字幕",
|
||||
"AI 자막 만들기": "生成 AI 字幕",
|
||||
|
|
@ -464,6 +465,8 @@
|
|||
"{0}개 · {1}": "{0} 个 · {1}",
|
||||
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
|
||||
"{0}개 맞춤": "已同步 {0} 个",
|
||||
"{0}개 보임": "显示 {0} 项",
|
||||
"{0}개 삭제": "删除 {0} 项",
|
||||
"{0}개 선택": "已选 {0} 个",
|
||||
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除",
|
||||
"{0}개 지우고 맞추기": "删除 {0} 个并同步",
|
||||
|
|
@ -541,6 +544,7 @@
|
|||
"가져올 즐겨찾기가 없습니다.": "没有可导入的书签。",
|
||||
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
|
||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
|
||||
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
|
||||
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
|
||||
"같은 이름이 이미 있습니다": "已存在同名项目",
|
||||
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
|
||||
|
|
@ -573,11 +577,15 @@
|
|||
"결과 폴더 열기: {0}": "打开输出文件夹: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})",
|
||||
"경로": "路径",
|
||||
"경로 {0}개를 복사했습니다": "已复制 {0} 个路径",
|
||||
"경로 복사": "复制路径",
|
||||
"경로 복사 ({0}개)": "复制路径 ({0} 个)",
|
||||
"경로 입력": "输入路径",
|
||||
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)",
|
||||
"경로 입력 · WebDAV": "输入路径 · WebDAV",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
|
||||
"경로를 복사했습니다: {0}": "已复制路径: {0}",
|
||||
"계속": "持续",
|
||||
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
|
||||
"계속 받기": "继续下载",
|
||||
|
|
@ -602,6 +610,9 @@
|
|||
"골라서 시작": "选择后开始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
|
||||
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
|
||||
"공유": "分享",
|
||||
"공유 (다른 앱으로 보내기)": "分享 (发送到其他应用)",
|
||||
"공유 창을 열 수 없습니다.": "无法打开分享窗口。",
|
||||
"관리 메뉴": "管理菜单",
|
||||
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)",
|
||||
"관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。",
|
||||
|
|
@ -778,6 +789,7 @@
|
|||
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "切换到其他页面或应用转到后台时,会立即清理 Tor 页面的网页和连接。Tor 本身会在这段时间内保持运行,返回后可立即使用。",
|
||||
"다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音",
|
||||
"다시 만들기 대기로": "改回等待重新制作",
|
||||
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)",
|
||||
"다시 받기": "重新下载",
|
||||
"다시 비교": "重新比较",
|
||||
"다시 설치": "重新安装",
|
||||
|
|
@ -961,6 +973,7 @@
|
|||
"많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多(较快):{0} - 若打开了很多其他应用,Android 可能会关闭本应用",
|
||||
"말레이어": "马来语",
|
||||
"망에 닿을 수 없음": "网络不可达",
|
||||
"맞는 것이 없습니다": "没有匹配项",
|
||||
"맞추기 (검은 여백)": "适应(黑边)",
|
||||
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
|
||||
"맞추기 (흐린 배경)": "适应(模糊背景)",
|
||||
|
|
@ -1060,10 +1073,12 @@
|
|||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留",
|
||||
"받다 만 AI 모델": "未下载完的 AI 模型",
|
||||
"받다 만 다운로드": "未完成的下载",
|
||||
"받다 만 조각 · {0}": "未完成的下载 · {0}",
|
||||
"받아서 열기": "下载后打开",
|
||||
"받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관",
|
||||
"받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})",
|
||||
"받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "已下载的 AI 模型 (图片 · 字幕) - 需要重新下载 (约 {0})",
|
||||
"받은 AI 자막 모델": "已下载的 AI 字幕模型",
|
||||
"받은 동영상을 편집 목록에 추가: {0}": "已将收到的视频加入编辑列表: {0}",
|
||||
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件",
|
||||
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)",
|
||||
|
|
@ -1074,10 +1089,12 @@
|
|||
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
|
||||
"받음": "已下载",
|
||||
"받음 {0}": "已接收 {0}",
|
||||
"받음 · {0}": "已下载 · {0}",
|
||||
"받지 못했습니다": "无法下载",
|
||||
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]",
|
||||
"받지 못했습니다. 잠시 뒤 다시 해 주세요": "下载失败。请稍后重试",
|
||||
"받지 못했습니다: {0}": "无法下载: {0}",
|
||||
"받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})",
|
||||
"밝게": "调亮",
|
||||
"밝기": "亮度",
|
||||
"밤 0~6시": "夜间 0~6 点",
|
||||
|
|
@ -1184,6 +1201,7 @@
|
|||
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
|
||||
"빠른 디코더 (TAESD)": "快速解码器 (TAESD)",
|
||||
"빠른 필터": "快速筛选",
|
||||
"빼고 싶은 것 (선택)": "要排除的内容 (可选)",
|
||||
"사라지는 것": "丢失的内容",
|
||||
"사본 만들기": "创建副本",
|
||||
|
|
@ -1722,6 +1740,7 @@
|
|||
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
|
||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
|
||||
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)",
|
||||
"이어 받기": "继续下载",
|
||||
"이전": "上一个",
|
||||
"이전 (P)": "上一个(P)",
|
||||
|
|
@ -1943,6 +1962,8 @@
|
|||
"즐겨찾기 추가…": "添加书签…",
|
||||
"즐겨찾기 추가됨": "已添加书签",
|
||||
"즐겨찾기 표시줄": "书签栏",
|
||||
"즐겨찾기에 넣었습니다: {0}": "已添加到收藏: {0}",
|
||||
"즐겨찾기에 추가": "添加到收藏",
|
||||
"즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)",
|
||||
"지금": "当前",
|
||||
"지금 {0}%": "当前 {0}%",
|
||||
|
|
@ -1968,6 +1989,7 @@
|
|||
"지금 짝을 즐겨찾기에": "将当前一对加入收藏",
|
||||
"지금 폴더 추가": "添加当前文件夹",
|
||||
"지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)",
|
||||
"지금 확인": "立即检查",
|
||||
"지금: {0}": "当前: {0}",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
|
||||
|
|
@ -2160,6 +2182,7 @@
|
|||
"폴더": "文件夹",
|
||||
"폴더 + 파일 목록": "文件夹 + 文件列表",
|
||||
"폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)",
|
||||
"폴더 {0}개는 빼고 파일만 보냈습니다.": "只发送了文件 (排除了 {0} 个文件夹)。",
|
||||
"폴더 고르기": "选择文件夹",
|
||||
"폴더 만들기": "新建文件夹",
|
||||
"폴더 복사 · 이동 방법": "文件夹复制 · 移动方式",
|
||||
|
|
@ -2178,6 +2201,7 @@
|
|||
"폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "文件夹不存在或无法打开。Windows 的网络共享格式为 \\\\服务器\\共享(转换后的路径:{0})",
|
||||
"폴더가 없거나 열 수 없습니다: {0}": "文件夹不存在或无法打开: {0}",
|
||||
"폴더가 없습니다": "文件夹不存在",
|
||||
"폴더는 공유할 수 없습니다. 파일을 고르세요.": "文件夹无法分享。请选择文件。",
|
||||
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。",
|
||||
"폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}",
|
||||
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "不能将文件夹复制或移动到其自身内部",
|
||||
|
|
@ -2214,6 +2238,7 @@
|
|||
"필요한 것 모두 받기": "下载全部所需文件",
|
||||
"필요한 공간 {0}": "所需空间 {0}",
|
||||
"필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}",
|
||||
"필터 닫기 (모두 보기)": "关闭筛选 (全部显示)",
|
||||
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。",
|
||||
"하위 폴더가 없습니다": "没有子文件夹",
|
||||
"한 번 더": "再输入一次",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ class AiOptions {
|
|||
bool get needsTranslation =>
|
||||
source == undetermined || targets.any((t) => t.code != source.code);
|
||||
|
||||
/// 259: 시작 전에 이미 번역이 필요한 것이 확실한지 - 원어를 골랐으면 다른 언어가 있을 때,
|
||||
/// 자동 감지면 만들 언어가 둘 이상일 때 (하나뿐이면 감지한 원어가 그 언어일 수 있어 감지한 뒤에 정한다)
|
||||
bool get surelyNeedsTranslation =>
|
||||
source == undetermined ? targets.length > 1 : targets.any((t) => t.code != source.code);
|
||||
|
||||
AiOptions copyWith({Language? source, Set<Language>? targets, ModelSpec? whisper}) => AiOptions(
|
||||
source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper);
|
||||
}
|
||||
|
|
@ -856,28 +861,31 @@ class AppController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
final label = targets.length == 1 ? trf('AI 자막: {0}', [targets.first.fileName]) : trf('AI 자막 {0}개', [targets.length]);
|
||||
return _enqueue(label, () => _runAi(targets, opts));
|
||||
return _enqueue(label, () => _usingAiModels(() => _runAi(targets, opts)));
|
||||
}
|
||||
|
||||
/// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게)
|
||||
bool get aiModelsInUse => _aiModelUsers > 0;
|
||||
int _aiModelUsers = 0;
|
||||
|
||||
Future<void> _usingAiModels(Future<void> Function() job) async {
|
||||
_aiModelUsers++;
|
||||
notifyListeners();
|
||||
try {
|
||||
await job();
|
||||
} finally {
|
||||
_aiModelUsers--;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runAi(List<VideoItem> targets, AiOptions opts) async {
|
||||
_aiCancelled = false;
|
||||
try {
|
||||
// 1. 모델 준비 (없으면 내려받기)
|
||||
for (final m in [opts.whisper, nllbModel]) {
|
||||
if (m == nllbModel && !opts.needsTranslation) continue;
|
||||
if (await services.models.isInstalled(m)) continue;
|
||||
_log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel]));
|
||||
await services.models.download(m,
|
||||
isCancelled: () => _aiCancelled,
|
||||
onProgress: (x) {
|
||||
for (final v in targets) {
|
||||
v
|
||||
..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()])
|
||||
..progress = x
|
||||
..status = JobStatus.running;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
// 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음
|
||||
for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) {
|
||||
await _ensureAiModel(m, targets);
|
||||
}
|
||||
for (final v in targets) {
|
||||
if (_aiCancelled) break;
|
||||
|
|
@ -898,6 +906,23 @@ class AppController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// AI 자막 모델이 없으면 내려받기 (진행은 [targets] 의 줄에)
|
||||
Future<void> _ensureAiModel(ModelSpec m, List<VideoItem> targets) async {
|
||||
if (await services.models.isInstalled(m)) return;
|
||||
_log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel]));
|
||||
await services.models.download(m,
|
||||
isCancelled: () => _aiCancelled,
|
||||
onProgress: (x) {
|
||||
for (final v in targets) {
|
||||
v
|
||||
..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()])
|
||||
..progress = x
|
||||
..status = JobStatus.running;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _generateOne(VideoItem v, AiOptions opts) async {
|
||||
void phase(String text, double p) {
|
||||
v
|
||||
|
|
@ -962,6 +987,9 @@ class AppController extends ChangeNotifier {
|
|||
out = [for (final c in cues) c.copy()];
|
||||
} else {
|
||||
if (_translator == null) {
|
||||
// 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다
|
||||
await _ensureAiModel(nllbModel, [v]);
|
||||
if (_aiCancelled) throw const AiCancelled();
|
||||
phase(tr('번역 모델 불러오는 중'), base);
|
||||
_translator = services.createTranslator!();
|
||||
await _translator!.load(await services.models.folderOf(nllbModel));
|
||||
|
|
@ -1122,7 +1150,7 @@ class AppController extends ChangeNotifier {
|
|||
if (busy) v.phase = tr('자막 번역 대기 중');
|
||||
notifyListeners();
|
||||
return _enqueue(trf('자막 번역: {0} → {1}', [v.fileName, targets.map((t) => t.code).join('/')]),
|
||||
() => _runTranslate(v, s, targets));
|
||||
() => _usingAiModels(() => _runTranslate(v, s, targets)));
|
||||
}
|
||||
|
||||
Future<void> _runTranslate(VideoItem v, SubtitleEntry s, Set<Language> targets) async {
|
||||
|
|
@ -1355,24 +1383,32 @@ class AppController extends ChangeNotifier {
|
|||
..message = null;
|
||||
}
|
||||
final skipped = <VideoItem>{};
|
||||
// 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게)
|
||||
final results = <VideoItem, JobStatus>{};
|
||||
Future<void> worker() async {
|
||||
while (!_buildCancelled && _batchNext < queue.length) {
|
||||
final v = queue[_batchNext++];
|
||||
if (!await _build(v, existing)) skipped.add(v);
|
||||
if (await _build(v, existing)) {
|
||||
results[v] = v.status;
|
||||
} else {
|
||||
skipped.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]);
|
||||
// P0-2: [남은 것 모두 취소] 로 시작하지 못한 것은 "취소됨"
|
||||
// P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨".
|
||||
// 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다
|
||||
for (final v in queue) {
|
||||
if (v.status == JobStatus.ready && !skipped.contains(v)) {
|
||||
if (!results.containsKey(v) && !skipped.contains(v)) {
|
||||
v
|
||||
..status = JobStatus.cancelled
|
||||
..message = tr('취소됨');
|
||||
results[v] = JobStatus.cancelled;
|
||||
}
|
||||
}
|
||||
// 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다
|
||||
int count(JobStatus s) => queue.where((v) => v.status == s && !skipped.contains(v)).length;
|
||||
int count(JobStatus s) => results.values.where((x) => x == s).length;
|
||||
_log(skipped.isEmpty
|
||||
? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}',
|
||||
[count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length])
|
||||
|
|
@ -1394,6 +1430,9 @@ class AppController extends ChangeNotifier {
|
|||
int get batchTotal => buildingMkv ? _batchQueue.length : 0;
|
||||
int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0;
|
||||
|
||||
/// 262: [v] 가 지금 만드는 묶음에 들어 있는지
|
||||
bool inCurrentBatch(VideoItem v) => buildingMkv && _batchQueue.contains(v);
|
||||
|
||||
/// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수
|
||||
int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0;
|
||||
|
||||
|
|
@ -1559,7 +1598,8 @@ class AppController extends ChangeNotifier {
|
|||
|
||||
/// 완료·실패 상태를 초기화해 다시 만들 수 있게 한다.
|
||||
void resetStatus(VideoItem v) {
|
||||
if (v.status == JobStatus.running) return;
|
||||
// 262: 지금 묶음의 것은 묶음이 끝난 뒤에 (끝나는 순간 ■ 자리에 ⟳ 가 나타나 ■ 를 누르려던 것이 ⟳ 로 들어갔다)
|
||||
if (v.status == JobStatus.running || inCurrentBatch(v)) return;
|
||||
v
|
||||
..status = JobStatus.ready
|
||||
..progress = 0
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ class AndroidShell extends NoopShell {
|
|||
}
|
||||
}
|
||||
|
||||
/// 77: Android 공유 창 (MainActivity "shareFiles")
|
||||
@override
|
||||
bool get canShare => true;
|
||||
|
||||
@override
|
||||
Future<bool> shareFiles(List<String> paths) async {
|
||||
try {
|
||||
return await _ch.invokeMethod<bool>('shareFiles', {'paths': paths}) ?? false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다
|
||||
@override
|
||||
Future<void> openExternal(String program, List<String> files) async {
|
||||
|
|
|
|||
|
|
@ -369,6 +369,13 @@ class DesktopShell with WindowListener implements AppShell {
|
|||
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
|
||||
|
||||
/// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더).
|
||||
/// 77: Windows 판은 공유 창을 아직 쓰지 않는다 (메뉴에 [공유] 를 두지 않음)
|
||||
@override
|
||||
bool get canShare => false;
|
||||
|
||||
@override
|
||||
Future<bool> shareFiles(List<String> paths) async => false;
|
||||
|
||||
/// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창
|
||||
@override
|
||||
Future<bool> openWith(String path, {bool choose = false}) async {
|
||||
|
|
|
|||
|
|
@ -177,7 +177,11 @@ class YtDlpBackend implements DownloadBackend {
|
|||
// 영상 · 음성을 따로 받아도 하나의 진행률 · 크기로 보이게 합친다
|
||||
final totals = YtDlpTotals();
|
||||
var files = 0;
|
||||
// 184-2: 사이트 추출기가 아닌 "generic" (직접 파일 등) 으로 받았는지 · 영상 · 음성을 합쳤는지
|
||||
var generic = false, merged = false;
|
||||
void onLine(String line) {
|
||||
if (line.startsWith('[generic]')) generic = true;
|
||||
if (line.startsWith('[Merger]')) merged = true;
|
||||
final prog = parseYtDlpProgress(line);
|
||||
if (prog != null) {
|
||||
totals.update(prog);
|
||||
|
|
@ -246,7 +250,9 @@ class YtDlpBackend implements DownloadBackend {
|
|||
}
|
||||
// 184-2: 동영상이 아닌 직접 파일 (확장자로 미리 알 수 없던 주소) 을 다 받았는데 후처리에서 실패 -
|
||||
// 받은 파일을 원래 이름으로 남기고 "완료" (예전에는 다 받고도 "실패 · 받지 못했습니다" 와 "… [id].unknown_video")
|
||||
if (isNonVideoPostprocessError(err) && await _keepAsPlainFile(t)) {
|
||||
// 사이트 추출기 (youtube 등) 나 합치기 실패는 진짜 동영상이 망가진 것일 수 있어 실패 그대로 (이유를 보인다)
|
||||
final plain = !merged && (generic || t.files.any((f) => f.endsWith('.unknown_video')));
|
||||
if (plain && isNonVideoPostprocessError(err) && await _keepAsPlainFile(t)) {
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ abstract class AppShell {
|
|||
/// 열지 못하면 false.
|
||||
Future<bool> openWith(String path, {bool choose = false});
|
||||
|
||||
/// 77: 파일을 다른 앱으로 보내기 (공유 창). 이 기기에서 못 하면 [canShare] 가 false
|
||||
bool get canShare;
|
||||
Future<bool> shareFiles(List<String> paths);
|
||||
|
||||
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
|
||||
List<String> installedBrowsers();
|
||||
|
||||
|
|
@ -139,6 +143,10 @@ class NoopShell implements AppShell {
|
|||
@override
|
||||
Future<bool> openWith(String path, {bool choose = false}) async => false;
|
||||
@override
|
||||
bool get canShare => false;
|
||||
@override
|
||||
Future<bool> shareFiles(List<String> paths) async => false;
|
||||
@override
|
||||
List<String> installedBrowsers() => const [];
|
||||
@override
|
||||
Future<List<String>> missingTools() async => const [];
|
||||
|
|
|
|||
|
|
@ -97,6 +97,42 @@ class ModelStore {
|
|||
/// 파일이 있거나, 외부 데이터 형식으로 바꿔 둔 것이 있으면 (Android 는 바꾼 뒤 원래 파일을 지운다)
|
||||
static bool _present(String path) => File(path).existsSync() || OnnxExternal.isReady(path);
|
||||
|
||||
/// 260: 이 모델이 차지한 파일 - 모델 파일 · 받다 만 조각 (.part) · Android 에서 바꿔 둔 형식 (_jj2.onnx · .data).
|
||||
/// 혼자 쓰는 폴더면 폴더 안 전부
|
||||
Future<List<File>> filesOf(ModelSpec m) async {
|
||||
final dir = Directory(await folderOf(m));
|
||||
if (!dir.existsSync()) return [];
|
||||
final shared = [...whisperModels, nllbModel].any((o) => o.id != m.id && o.folder == m.folder);
|
||||
if (!shared) return [for (final e in dir.listSync(recursive: true)) if (e is File) e];
|
||||
return [
|
||||
for (final f in m.files)
|
||||
for (final path in {p.join(dir.path, f.name), '${p.join(dir.path, f.name)}.part'})
|
||||
if (File(path).existsSync()) File(path),
|
||||
];
|
||||
}
|
||||
|
||||
/// 260: 차지한 크기 (바이트, 없으면 0)
|
||||
Future<int> usedBytes(ModelSpec m) async {
|
||||
var n = 0;
|
||||
for (final f in await filesOf(m)) {
|
||||
try {
|
||||
n += f.lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 260: 모델 지우기 (다시 쓰면 그때 다시 받는다). 혼자 쓰는 빈 폴더도 지운다
|
||||
Future<void> remove(ModelSpec m) async {
|
||||
for (final f in await filesOf(m)) {
|
||||
await f.delete();
|
||||
}
|
||||
final dir = Directory(await folderOf(m));
|
||||
if (dir.existsSync() && dir.listSync(recursive: true).whereType<File>().isEmpty) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 없는 파일만 내려받는다. [onProgress] 0.0~1.0, [isCancelled] 가 true 면 중단.
|
||||
Future<void> download(
|
||||
ModelSpec m, {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,14 @@ class _AiDialogState extends State<_AiDialog> {
|
|||
style: TextStyle(fontSize: 11, color: ok ? JjColors.success : JjColors.textDim));
|
||||
}
|
||||
|
||||
/// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다
|
||||
Widget _nllbStatus() {
|
||||
if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel);
|
||||
const style = TextStyle(fontSize: 11, color: JjColors.textDim);
|
||||
if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style);
|
||||
return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extra = languages.where((l) => !_o.targets.contains(l)).toList();
|
||||
|
|
@ -290,7 +298,7 @@ class _AiDialogState extends State<_AiDialog> {
|
|||
),
|
||||
Row(children: [
|
||||
Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)),
|
||||
_status(nllbModel),
|
||||
Flexible(child: _nllbStatus()),
|
||||
]),
|
||||
const NllbLicenseNote(),
|
||||
if (widget.videoCount > 1) ...[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/ai_local.dart' show AiStore;
|
||||
import '../app/app_controller.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../services/model_store.dart';
|
||||
import 'confirm.dart';
|
||||
import 'setting_tile.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 260: 받은 AI 자막 모델 (Whisper · NLLB) - 차지한 크기 · [지우기]. 다시 쓰면 그때 다시 받는다
|
||||
class AiSubtitleModels extends StatefulWidget {
|
||||
final AppController c;
|
||||
const AiSubtitleModels({super.key, required this.c});
|
||||
|
||||
@override
|
||||
State<AiSubtitleModels> createState() => _AiSubtitleModelsState();
|
||||
}
|
||||
|
||||
class _AiSubtitleModelsState extends State<AiSubtitleModels> {
|
||||
/// 모델 id → 차지한 바이트 (0 이면 받지 않음)
|
||||
Map<String, int>? _used;
|
||||
|
||||
/// 다 받은 모델 (나머지 중 크기가 있으면 받다 만 조각)
|
||||
final _installed = <String>{};
|
||||
String? _error;
|
||||
|
||||
ModelStore get _store => widget.c.services.models;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final used = {for (final m in [...whisperModels, nllbModel]) m.id: await _store.usedBytes(m)};
|
||||
_installed
|
||||
..clear()
|
||||
..addAll([for (final m in [...whisperModels, nllbModel]) if (await _store.isInstalled(m)) m.id]);
|
||||
if (mounted) setState(() => _used = used);
|
||||
}
|
||||
|
||||
Future<void> _remove(ModelSpec m) async {
|
||||
final ok = await confirmAction(context,
|
||||
title: trf('{0} 을(를) 지울까요?', [m.label]), body: tr('다시 쓰려면 다시 받아야 합니다.'), ok: tr('지우기'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await _store.remove(m);
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
_error = trf('지우지 못했습니다: {0}', [e]);
|
||||
}
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final used = _used;
|
||||
if (used == null) return const SizedBox();
|
||||
return ListenableBuilder(
|
||||
listenable: widget.c,
|
||||
builder: (context, _) {
|
||||
final inUse = widget.c.aiModelsInUse;
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final m in [...whisperModels, nllbModel])
|
||||
SettingTile(
|
||||
dense: true,
|
||||
leading: Icon(used[m.id]! > 0 ? Icons.check_circle : Icons.cloud_download_outlined,
|
||||
color: used[m.id]! > 0 ? JjColors.success : JjColors.textDim, size: 20),
|
||||
title: Text(m.label),
|
||||
subtitle: Text(used[m.id]! > 0
|
||||
? trf(_installed.contains(m.id) ? '받음 · {0}' : '받다 만 조각 · {0}', [AiStore.sizeText(used[m.id]!)])
|
||||
: trf('받지 않음 (처음 쓸 때 내려받기 {0})', [m.sizeLabel])),
|
||||
trailing: used[m.id]! > 0
|
||||
? inUse
|
||||
? Tooltip(
|
||||
message: tr('AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다'),
|
||||
child: TextButton(onPressed: null, child: Text(tr('지우기'))),
|
||||
)
|
||||
: TextButton(onPressed: () => _remove(m), child: Text(tr('지우기')))
|
||||
: null,
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: Text(_error!, style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import 'package:path/path.dart' as p;
|
|||
import '../app/app_controller.dart';
|
||||
import '../app/copy_center.dart';
|
||||
import '../app/live_sync.dart';
|
||||
import '../app/settings.dart' show CopyTask;
|
||||
import '../app/settings.dart' show CopyTask, FolderBookmark;
|
||||
import '../app/transfer_job.dart';
|
||||
import '../core/secret_gate.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
|
|
@ -217,6 +217,12 @@ class _Pane extends ChangeNotifier {
|
|||
/// 74: 범위 고르기의 시작 (마지막으로 표시한 것)
|
||||
String? anchor;
|
||||
|
||||
/// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움)
|
||||
bool filterOpen = false;
|
||||
final filterCtl = TextEditingController();
|
||||
final filterFocus = FocusNode();
|
||||
String get filter => filterOpen ? filterCtl.text.trim() : '';
|
||||
|
||||
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
|
||||
final scroll = ScrollController();
|
||||
|
||||
|
|
@ -241,6 +247,8 @@ class _Pane extends ChangeNotifier {
|
|||
scroll.dispose();
|
||||
listScroll.dispose();
|
||||
tabScroll.dispose();
|
||||
filterCtl.dispose();
|
||||
filterFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -303,6 +311,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
int _active = 0;
|
||||
bool _ready = false;
|
||||
|
||||
/// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다
|
||||
final _keys = FocusNode(debugLabel: 'explorer');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -346,6 +357,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_keys.dispose();
|
||||
if (widget.rsync) {
|
||||
ExplorerPage._openRsync--;
|
||||
} else {
|
||||
|
|
@ -616,6 +628,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
|
||||
String _copyablePath(String path) {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
try {
|
||||
return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString();
|
||||
} catch (_) {
|
||||
return _displayPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
|
||||
String _displayPath(String path) {
|
||||
if (isDav(path)) {
|
||||
|
|
@ -859,13 +882,23 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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;
|
||||
// 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로).
|
||||
// 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게)
|
||||
bool walk(String dir, int depth) {
|
||||
if (!pane.expanded.contains(dir)) return false;
|
||||
var kept = false;
|
||||
for (final e in pane.cache[dir] ?? const <FileEntry>[]) {
|
||||
if (foldersOnly && !e.isDir) continue;
|
||||
final at = out.length;
|
||||
out.add(_Row(e, depth));
|
||||
if (e.isDir) walk(e.path, depth + 1);
|
||||
final inner = e.isDir && walk(e.path, depth + 1);
|
||||
if (!inner && !_passes(pane, e)) {
|
||||
out.length = at;
|
||||
} else {
|
||||
kept = true;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
walk(pane.root, 1);
|
||||
|
|
@ -950,9 +983,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a);
|
||||
}
|
||||
|
||||
/// 76: 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다
|
||||
bool _passes(_Pane pane, FileEntry e) =>
|
||||
pane.filter.isEmpty ||
|
||||
!isSameOrInside(e.path, pane.current) ||
|
||||
samePath(e.path, pane.current) ||
|
||||
quickFilterMatch(vBasename(e.path), pane.filter);
|
||||
|
||||
/// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것)
|
||||
List<FileEntry> _shown(_Pane pane, String dir) => [for (final x in pane.cache[dir] ?? const <FileEntry>[]) if (_passes(pane, x)) x];
|
||||
|
||||
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
|
||||
void _markRange(_Pane pane, FileEntry to) {
|
||||
final list = pane.cache[vDirname(to.path)] ?? const <FileEntry>[];
|
||||
final list = _shown(pane, vDirname(to.path));
|
||||
final a = list.indexWhere((x) => samePath(x.path, pane.anchor!));
|
||||
final b = list.indexWhere((x) => samePath(x.path, to.path));
|
||||
if (a < 0 || b < 0) return;
|
||||
|
|
@ -968,14 +1011,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
|
||||
/// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서
|
||||
void _invertMarks(_Pane pane, String dir) => setState(() {
|
||||
for (final x in pane.cache[dir] ?? const <FileEntry>[]) {
|
||||
for (final x in _shown(pane, dir)) {
|
||||
if (!pane.marked.remove(x.path)) pane.marked.add(x.path);
|
||||
}
|
||||
_normalizeMarks(pane);
|
||||
});
|
||||
|
||||
void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() {
|
||||
pane.marked.addAll([for (final x in pane.cache[dir] ?? const <FileEntry>[]) if (test(x)) x.path]);
|
||||
pane.marked.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]);
|
||||
_normalizeMarks(pane);
|
||||
});
|
||||
|
||||
|
|
@ -1244,6 +1287,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final viewable = viewer && !e.isDir &&
|
||||
(c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
|
||||
final dual = _dual;
|
||||
// 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사)
|
||||
final targets = _isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path];
|
||||
final many = targets.length > 1;
|
||||
// 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
|
||||
final items = widget.rsync
|
||||
? <(String, IconData, String)>[
|
||||
|
|
@ -1257,6 +1303,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
('info', Icons.info_outline, tr('정보')),
|
||||
]
|
||||
: <(String, IconData, String)>[
|
||||
// 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목
|
||||
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
|
||||
if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
|
||||
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
|
||||
|
|
@ -1271,23 +1318,41 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
('toPdf', Icons.picture_as_pdf_outlined,
|
||||
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
|
||||
('select', Icons.check_circle_outline, tr('선택')),
|
||||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||||
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
|
||||
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')),
|
||||
// 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기
|
||||
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
|
||||
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
|
||||
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
|
||||
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
|
||||
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
|
||||
if (_clip.isNotEmpty)
|
||||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])),
|
||||
('delete', Icons.delete_outline, tr('삭제')),
|
||||
('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')),
|
||||
if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path)))
|
||||
('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')),
|
||||
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||||
('info', Icons.info_outline, tr('정보')),
|
||||
];
|
||||
// 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에)
|
||||
final icons = widget.rsync
|
||||
? const <(String, IconData, String, String, bool)>[]
|
||||
: <(String, IconData, String, String, bool)>[
|
||||
(dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true),
|
||||
(dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true),
|
||||
('rename', Icons.drive_file_rename_outline, tr('이름 변경'), tr('이름 변경'), !many),
|
||||
('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true),
|
||||
if (c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))),
|
||||
];
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final pick = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
|
||||
items: [
|
||||
// 77: 여러 개를 골랐으면 무엇에 하는지 먼저
|
||||
if (many)
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
height: 32,
|
||||
child: Text(trf('{0}개 항목', [targets.length]),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||||
),
|
||||
if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)],
|
||||
for (final (id, icon, label) in items)
|
||||
PopupMenuItem(
|
||||
value: id,
|
||||
|
|
@ -1339,7 +1404,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
case 'paste':
|
||||
await _paste(pane, e.isDir ? e.path : vDirname(e.path));
|
||||
case 'delete':
|
||||
await _delete(pane, [e.path]);
|
||||
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
|
||||
await _delete(pane, targets);
|
||||
case 'share':
|
||||
await _share(targets);
|
||||
case 'copyPath':
|
||||
final paths = targets.map(_copyablePath).toList();
|
||||
await Clipboard.setData(ClipboardData(text: paths.join('\n')));
|
||||
_snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single]));
|
||||
case 'bookmark':
|
||||
final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path);
|
||||
await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]);
|
||||
_snack(trf('즐겨찾기에 넣었습니다: {0}', [name]));
|
||||
setState(() {});
|
||||
case 'view':
|
||||
await _open(e.path, pane: pane);
|
||||
case 'toPdf':
|
||||
|
|
@ -1351,6 +1428,26 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
|
||||
Future<void> _share(List<String> paths) async {
|
||||
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
|
||||
if (files.isEmpty) {
|
||||
_snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
|
||||
return;
|
||||
}
|
||||
final local = <String>[];
|
||||
for (final f in files) {
|
||||
final l = await _fetch(f);
|
||||
if (l == null || !mounted) return;
|
||||
local.add(l);
|
||||
}
|
||||
if (!await c.services.shell.shareFiles(local)) {
|
||||
_snack(tr('공유 창을 열 수 없습니다.'));
|
||||
} else if (files.length < paths.length) {
|
||||
_snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── 기능 ─────────
|
||||
|
||||
_Pane get _pane => _panes[_active];
|
||||
|
|
@ -1375,6 +1472,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 265: 복사 · 이동 뒤 - 대상 · 원본의 위 폴더와, 펼쳐 둔 원본 · 대상 안쪽까지 두 창 모두 다시 읽는다.
|
||||
/// (예전에는 원본의 위 폴더만 읽어, 합치다 남은 원본 폴더 아래에 옮겨진 파일이 계속 보였다) 없어진 폴더는 접는다
|
||||
Future<void> _refreshAfterTransfer(List<String> sources, String dest) async {
|
||||
for (final x in _panes) {
|
||||
final dirs = {
|
||||
dest,
|
||||
...sources.map(vDirname),
|
||||
for (final k in x.cache.keys)
|
||||
if (isSameOrInside(k, dest) || sources.any((s) => isSameOrInside(k, s))) k,
|
||||
};
|
||||
for (final d in dirs) {
|
||||
if (!mounted) return;
|
||||
if (!x.cache.containsKey(d)) continue;
|
||||
if (!await vExists(d)) {
|
||||
x.cache.remove(d);
|
||||
x.expanded.remove(d);
|
||||
continue;
|
||||
}
|
||||
// 180: 비우지 않고 새로 읽은 것으로 바꾼다 (자리 · 스크롤 그대로)
|
||||
await _load(x, d, force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 바뀐 폴더를 두 창 모두 다시 읽는다
|
||||
Future<void> _refreshAll(Iterable<String> dirs) async {
|
||||
for (final pane in _panes) {
|
||||
|
|
@ -2018,7 +2139,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (!mounted) return false;
|
||||
// 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다)
|
||||
pane.marked.clear();
|
||||
await _refreshAll([dest, ...sources.map(p.dirname)]);
|
||||
await _refreshAfterTransfer(sources, dest);
|
||||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||||
for (final x in _panes) {
|
||||
if (samePath(x.current, dest)) {
|
||||
|
|
@ -2033,6 +2154,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? trf('끝나지 못했습니다: {0}', [error])
|
||||
: [
|
||||
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
|
||||
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
||||
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]),
|
||||
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것 {0}개 건너뜀', [job.keptNewer]),
|
||||
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]),
|
||||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
|
||||
].join(' · '));
|
||||
|
|
@ -2789,8 +2913,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename),
|
||||
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게)
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) _setSelecting(false);
|
||||
// 76: 열린 빠른 필터부터 닫는다
|
||||
if (_panes[_active].filterOpen) {
|
||||
_closeFilter(_panes[_active]);
|
||||
} else if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) {
|
||||
_setSelecting(false);
|
||||
}
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyF, control: true): () => _openFilter(_panes[_active]),
|
||||
const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up),
|
||||
// 75: 지금 창의 뒤로 · 앞으로
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false),
|
||||
|
|
@ -2799,7 +2929,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
setState(() => _active = _dual ? 1 - _active : 0),
|
||||
},
|
||||
child: Focus(
|
||||
focusNode: _keys,
|
||||
autofocus: true,
|
||||
// 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼)
|
||||
onKeyEvent: (_, ev) => _typeToFilter(ev),
|
||||
child: Listener(
|
||||
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame])
|
||||
onPointerDown: (ev) => _mouseTravel(_active, ev),
|
||||
|
|
@ -3066,7 +3199,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
final dir = listDir ?? pane.current;
|
||||
pane.marked.addAll([for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path]);
|
||||
pane.marked.addAll([for (final x in _shown(pane, dir)) x.path]);
|
||||
_normalizeMarks(pane);
|
||||
}),
|
||||
child: Text(tr('모두 선택')),
|
||||
|
|
@ -3086,6 +3219,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
return LayoutBuilder(builder: (context, box) => _frame(i, [
|
||||
_paneHeader(pane),
|
||||
const Divider(height: 1),
|
||||
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
|
||||
if (pane.filterOpen)
|
||||
_filterBar(pane, rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length),
|
||||
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
||||
?_noticeArea([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight),
|
||||
if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting),
|
||||
|
|
@ -3095,7 +3231,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
controller: pane.scroll,
|
||||
itemCount: rows.length,
|
||||
itemExtent: look.rowHeightOf(context),
|
||||
itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly),
|
||||
// 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey])
|
||||
itemBuilder: (_, k) => KeyedSubtree(key: _rowKey(rows[k]), child: _rowView(pane, rows[k], compact: foldersOnly)),
|
||||
findChildIndexCallback: _rowIndexOf(rows),
|
||||
),
|
||||
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
||||
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
||||
|
|
@ -3105,14 +3243,26 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
]));
|
||||
}
|
||||
|
||||
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
|
||||
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
|
||||
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
|
||||
static Key _rowKey(_Row r) => ValueKey<String>('${r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
|
||||
|
||||
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
|
||||
static int? Function(Key) _rowIndexOf(List<_Row> rows) {
|
||||
Map<Key, int>? index;
|
||||
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key];
|
||||
}
|
||||
|
||||
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
||||
Widget _listView(_Pane pane) {
|
||||
final dir = pane.current;
|
||||
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
||||
final up = !samePath(dir, pane.root);
|
||||
final shown = _shown(pane, dir);
|
||||
final rows = [
|
||||
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||||
for (final e in entries) _Row(e, 0),
|
||||
for (final e in shown) _Row(e, 0),
|
||||
];
|
||||
final look = _look;
|
||||
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
||||
|
|
@ -3124,6 +3274,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (pane.filterOpen) _filterBar(pane, shown.length),
|
||||
?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight),
|
||||
if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting),
|
||||
Expanded(
|
||||
|
|
@ -3136,13 +3287,94 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
controller: pane.listScroll,
|
||||
itemCount: rows.length,
|
||||
itemExtent: look.rowHeightOf(context),
|
||||
itemBuilder: (_, k) => _rowView(pane, rows[k], list: true),
|
||||
itemBuilder: (_, k) => KeyedSubtree(key: _rowKey(rows[k]), child: _rowView(pane, rows[k], list: true)),
|
||||
findChildIndexCallback: _rowIndexOf(rows),
|
||||
),
|
||||
),
|
||||
if (_selecting) _marksBar(pane, listDir: dir),
|
||||
]));
|
||||
}
|
||||
|
||||
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
|
||||
void _openFilter(_Pane pane, [String? text]) {
|
||||
setState(() {
|
||||
_active = _panes.indexOf(pane);
|
||||
pane.filterOpen = true;
|
||||
if (text != null) {
|
||||
pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus());
|
||||
// 거른 결과 (지금 폴더 아래) 가 보이게
|
||||
_reveal(pane, pane.current);
|
||||
}
|
||||
|
||||
/// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로
|
||||
void _closeFilter(_Pane pane) {
|
||||
setState(() {
|
||||
pane
|
||||
..filterOpen = false
|
||||
..filterCtl.clear();
|
||||
});
|
||||
_reveal(pane, pane.focused ?? pane.current);
|
||||
_keys.requestFocus();
|
||||
}
|
||||
|
||||
KeyEventResult _typeToFilter(KeyEvent ev) {
|
||||
if (ev is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final ch = ev.character;
|
||||
final kb = HardwareKeyboard.instance;
|
||||
if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored;
|
||||
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
|
||||
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
|
||||
if (FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (!_ready || _panes.isEmpty) return KeyEventResult.ignored;
|
||||
final pane = _panes[_active];
|
||||
_openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
|
||||
Widget _filterBar(_Pane pane, int shown) {
|
||||
final none = pane.filter.isNotEmpty && shown == 0;
|
||||
return Container(
|
||||
height: 40,
|
||||
color: JjColors.panel,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(children: [
|
||||
Icon(Icons.filter_alt, size: 16, color: JjColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: CallbackShortcuts(
|
||||
bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => _closeFilter(pane)},
|
||||
child: TextField(
|
||||
controller: pane.filterCtl,
|
||||
focusNode: pane.filterFocus,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pane.filter.isNotEmpty)
|
||||
Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]),
|
||||
style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)),
|
||||
IconButton(
|
||||
tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () => _closeFilter(pane),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 창 위쪽: 저장 장치 고르기
|
||||
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
||||
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
||||
|
|
@ -3206,6 +3438,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
color: Colors.amber),
|
||||
onPressed: () => _bookmarks(pane),
|
||||
),
|
||||
// 76: 빠른 필터 (켜져 있으면 강조)
|
||||
IconButton(
|
||||
tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
isSelected: pane.filterOpen,
|
||||
icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined,
|
||||
size: 16, color: pane.filterOpen ? JjColors.accent : null),
|
||||
onPressed: () => pane.filterOpen ? _closeFilter(pane) : _openFilter(pane),
|
||||
),
|
||||
];
|
||||
|
||||
/// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로
|
||||
|
|
@ -3962,6 +4203,13 @@ class _Thumb extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시)
|
||||
bool quickFilterMatch(String name, String filter) {
|
||||
if (filter.isEmpty) return true;
|
||||
if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name);
|
||||
return name.toLowerCase().contains(filter.toLowerCase());
|
||||
}
|
||||
|
||||
/// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다"
|
||||
/// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시)
|
||||
RegExp globToRegExp(String pattern) {
|
||||
|
|
@ -4083,3 +4331,57 @@ class _PathDialogState extends State<_PathDialog> {
|
|||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||||
class _MenuIconRow extends PopupMenuEntry<String> {
|
||||
final List<(String, IconData, String, String, bool)> items;
|
||||
const _MenuIconRow(this.items);
|
||||
|
||||
@override
|
||||
double get height => 60;
|
||||
|
||||
@override
|
||||
bool represents(String? value) => false;
|
||||
|
||||
@override
|
||||
State<_MenuIconRow> createState() => _MenuIconRowState();
|
||||
}
|
||||
|
||||
class _MenuIconRowState extends State<_MenuIconRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final (id, icon, label, tip, on) in widget.items)
|
||||
Tooltip(
|
||||
message: tip,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: on ? () => Navigator.pop(context, id) : null,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: on,
|
||||
label: tip,
|
||||
excludeSemantics: true,
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Opacity(
|
||||
opacity: on ? 1 : 0.38,
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(height: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class HomePage extends StatelessWidget {
|
|||
),
|
||||
const Divider(height: 1),
|
||||
// 249: DNS 서버에 닿지 않는 동안 작업 기록 위에 (아래 안내를 놓쳐도 MKV 화면에서 보고 바꿀 수 있게)
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: DnsFailureLine(c: c)),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: DnsFailureLine(c: c, compact: isCompact(context))),
|
||||
if (c.settings.showLog)
|
||||
SizedBox(
|
||||
// 화면이 낮은 기기 (휴대폰 가로) 에서 목록 · 자세히 보기를 가리지 않도록 화면 높이에 맞춘다.
|
||||
|
|
@ -934,10 +934,11 @@ class _VideoTile extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled)
|
||||
// 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게)
|
||||
IconButton(
|
||||
tooltip: tr('다시 만들기 대기로'),
|
||||
tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'),
|
||||
iconSize: 16,
|
||||
onPressed: () => c.resetStatus(v),
|
||||
onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v),
|
||||
icon: const Icon(Icons.refresh, color: JjColors.textDim),
|
||||
),
|
||||
// 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로)
|
||||
|
|
@ -1075,7 +1076,53 @@ class _VideoDetail extends StatelessWidget {
|
|||
|
||||
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked,
|
||||
{Widget? moveButtons, double bottom = 88}) {
|
||||
final side = isCompact(context) ? 12.0 : 20.0;
|
||||
final compact = isCompact(context);
|
||||
final side = compact ? 12.0 : 20.0;
|
||||
// 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게
|
||||
final detected = v.aiDetected == null
|
||||
? null
|
||||
: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [
|
||||
Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
TextButton(
|
||||
onPressed: c.busy
|
||||
? null
|
||||
: () async {
|
||||
final lang = await pickSourceLanguage(context, languageOf(v.aiDetected));
|
||||
if (lang != null) await c.generateAiSubtitles([v], c.aiOptions.copyWith(source: lang));
|
||||
},
|
||||
child: Text(tr('다른 언어로 다시')),
|
||||
),
|
||||
]);
|
||||
// 자막 머리 줄: 좁으면 버튼이 다음 줄로 (오른쪽 정렬)
|
||||
final subtitleActions = Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [
|
||||
if (c.aiAvailable)
|
||||
FilledButton.icon(
|
||||
onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v),
|
||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: Text(tr('AI 자막 만들기')),
|
||||
),
|
||||
if (c.subtitleProvider != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: locked ? null : () => showSubtitleSearch(context, c, v),
|
||||
icon: const Icon(Icons.travel_explore, size: 18),
|
||||
label: Text(tr('인터넷 자막 찾기')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: locked ? null : () => c.pickSubtitlesFor(v),
|
||||
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||||
label: Text(tr('자막 파일 추가')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(side, side, side, bottom),
|
||||
children: [
|
||||
|
|
@ -1111,26 +1158,16 @@ class _VideoDetail extends StatelessWidget {
|
|||
],
|
||||
_Chip(trf('음성 {0}개', [audioCount])),
|
||||
]),
|
||||
if (moveButtons != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
moveButtons,
|
||||
],
|
||||
// 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게
|
||||
if (v.aiDetected != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [
|
||||
Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
TextButton(
|
||||
onPressed: c.busy
|
||||
? null
|
||||
: () async {
|
||||
final lang = await pickSourceLanguage(context, languageOf(v.aiDetected));
|
||||
if (lang != null) await c.generateAiSubtitles([v], c.aiOptions.copyWith(source: lang));
|
||||
},
|
||||
child: Text(tr('다른 언어로 다시')),
|
||||
),
|
||||
]),
|
||||
// 261: 좁은 화면 (폰 세로) 은 세부 정보 칸이 낮다 (노트 10+ 에서 약 210) - "감지한 언어" 줄 · 이동 버튼이
|
||||
// [AI 자막 만들기] 를 칸 밖으로 밀었다. 자막 버튼을 칩 바로 아래에 두고, 감지한 언어 · 이동 버튼은 그 다음
|
||||
if (compact) ...[
|
||||
const SizedBox(height: 12),
|
||||
subtitleActions,
|
||||
if (detected != null) ...[const SizedBox(height: 4), detected],
|
||||
if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons],
|
||||
] else ...[
|
||||
if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons],
|
||||
if (detected != null) ...[const SizedBox(height: 8), detected],
|
||||
],
|
||||
if (v.message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -1144,36 +1181,10 @@ class _VideoDetail extends StatelessWidget {
|
|||
style: const TextStyle(color: JjColors.danger, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
// 좁으면 버튼이 다음 줄로 (오른쪽 정렬)
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [
|
||||
if (c.aiAvailable)
|
||||
FilledButton.icon(
|
||||
onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v),
|
||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: Text(tr('AI 자막 만들기')),
|
||||
),
|
||||
if (c.subtitleProvider != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: locked ? null : () => showSubtitleSearch(context, c, v),
|
||||
icon: const Icon(Icons.travel_explore, size: 18),
|
||||
label: Text(tr('인터넷 자막 찾기')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: locked ? null : () => c.pickSubtitlesFor(v),
|
||||
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||||
label: Text(tr('자막 파일 추가')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
if (!compact) ...[
|
||||
const SizedBox(height: 24),
|
||||
subtitleActions,
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
if (v.subtitles.isEmpty)
|
||||
Padding(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import 'tor_page.dart' show showTorNotice;
|
|||
/// 서버가 다시 답하거나 DNS 설정을 바꾸면 사라진다.
|
||||
class DnsFailureLine extends StatelessWidget {
|
||||
final AppController c;
|
||||
const DnsFailureLine({super.key, required this.c});
|
||||
|
||||
/// 266: 좁은 화면 (폰 세로) - 한 줄 (글은 줄임, 길게 누르면 전체 · 버튼은 같은 줄). 두 줄이면 위의 세부 정보 칸이 줄어
|
||||
/// [AI 자막 만들기] · [인터넷 자막 찾기] 가 칸 밖으로 밀렸다
|
||||
final bool compact;
|
||||
const DnsFailureLine({super.key, required this.c, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ValueListenableBuilder<DnsFailure?>(
|
||||
|
|
@ -26,12 +30,33 @@ class DnsFailureLine extends StatelessWidget {
|
|||
builder: (context, f, _) {
|
||||
if (f == null) return const SizedBox.shrink();
|
||||
String two(int x) => x.toString().padLeft(2, '0');
|
||||
final text = trf('DNS 서버 ({0}) 에 닿지 않음 ({1}부터)', [f.server, '${two(f.at.hour)}:${two(f.at.minute)}']);
|
||||
if (compact) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Row(children: [
|
||||
const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: text,
|
||||
child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Colors.orange)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(0, 36)),
|
||||
onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'),
|
||||
child: Text(tr('시스템 DNS 로 바꾸기'), style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.orange),
|
||||
Text(trf('DNS 서버 ({0}) 에 닿지 않음 ({1}부터)', [f.server, '${two(f.at.hour)}:${two(f.at.minute)}']),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange)),
|
||||
Text(text, style: const TextStyle(fontSize: 12, color: Colors.orange)),
|
||||
TextButton(
|
||||
onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'),
|
||||
child: Text(tr('시스템 DNS 로 바꾸기')),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import 'browser_page.dart' show deleteExportedCookies;
|
|||
import 'security_settings.dart';
|
||||
import 'network_settings.dart';
|
||||
import 'ai_settings.dart';
|
||||
import 'ai_subtitle_models.dart';
|
||||
import '../app/master_lock.dart';
|
||||
import 'toast_status.dart';
|
||||
import 'license_texts.dart';
|
||||
|
|
@ -541,6 +542,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
title: Text(tr('시작할 때마다 설정 창 보기')),
|
||||
subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])),
|
||||
),
|
||||
// 260: 받은 자막 모델 크기 · 지우기
|
||||
_subTitle(tr('받은 AI 자막 모델')),
|
||||
AiSubtitleModels(c: c),
|
||||
_subTitle(tr('인터넷 자막 (OpenSubtitles.com)')),
|
||||
_textTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey,
|
||||
tr('가입 → 프로필 → API consumers → New consumer'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
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/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/model_store.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/ai_subtitle_models.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 260: 받은 AI 자막 모델 (Whisper · NLLB) 을 앱 안에서 크기 보고 지우기
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late ModelStore models;
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_m260_');
|
||||
models = ModelStore(tmp.path);
|
||||
// whisper base 만 받음 (small 은 받다 만 조각), NLLB 는 Android 가 바꿔 둔 파일까지
|
||||
File(await models.pathOf(whisperModels[0]))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(3000, 1));
|
||||
File('${await models.pathOf(whisperModels[1])}.part').writeAsBytesSync(List.filled(500, 1));
|
||||
final nllb = await models.folderOf(nllbModel);
|
||||
for (final n in ['tokenizer.json', 'encoder_model_quantized_jj2.onnx', 'encoder_model_quantized_jj2.onnx.data',
|
||||
'decoder_model_merged_quantized_jj2.onnx', 'decoder_model_merged_quantized_jj2.onnx.data']) {
|
||||
File(p.join(nllb, n))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(1000, 1));
|
||||
}
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
test('크기 · 지우기: 같은 폴더의 다른 Whisper 는 남기고, NLLB 는 폴더째', () async {
|
||||
expect(await models.usedBytes(whisperModels[0]), 3000);
|
||||
expect(await models.usedBytes(whisperModels[1]), 500, reason: '받다 만 조각도 셈');
|
||||
expect(await models.usedBytes(nllbModel), 5000);
|
||||
await models.remove(whisperModels[0]);
|
||||
expect(File(await models.pathOf(whisperModels[0])).existsSync(), isFalse);
|
||||
expect(File('${await models.pathOf(whisperModels[1])}.part').existsSync(), isTrue, reason: '다른 모델 것은 남김');
|
||||
await models.remove(nllbModel);
|
||||
expect(Directory(await models.folderOf(nllbModel)).existsSync(), isFalse);
|
||||
expect(await models.usedBytes(nllbModel), 0);
|
||||
});
|
||||
|
||||
testWidgets('환경 설정 목록: 받은 것은 크기 · [지우기] → 확인 뒤 지움', (tester) async {
|
||||
final c = AppController(PlatformServices(
|
||||
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), models: models));
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(MaterialApp(home: Scaffold(body: ListView(children: [AiSubtitleModels(c: c)]))));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.text(whisperModels[0].label), findsOneWidget);
|
||||
expect(find.textContaining('받음'), findsNWidgets(2));
|
||||
expect(find.textContaining('받다 만 조각'), findsOneWidget);
|
||||
expect(find.text('지우기'), findsNWidgets(3));
|
||||
await tester.tap(find.text('지우기').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, '지우기'));
|
||||
await tester.pumpAndSettle();
|
||||
// 지우기는 실제 파일을 하나씩 지운다 - 다 지워질 때까지 실제 시간을 조금씩 준다
|
||||
final nllbDir = Directory((await tester.runAsync(() => models.folderOf(nllbModel)))!);
|
||||
for (var i = 0; i < 50 && find.textContaining('받지 않음').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(nllbDir.existsSync(), isFalse);
|
||||
expect(find.text('지우기'), findsNWidgets(2));
|
||||
expect(find.textContaining('받지 않음'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -54,6 +54,21 @@ class _FakeTranslator implements Translator {
|
|||
Future<void> dispose() async => disposed = true;
|
||||
}
|
||||
|
||||
/// 259: 받은 모델을 기록하는 가짜 모델 저장소 (인터넷 대신 빈 파일)
|
||||
class _CountingModels extends ModelStore {
|
||||
_CountingModels(super.dir);
|
||||
final downloaded = <String>[];
|
||||
@override
|
||||
Future<void> download(ModelSpec m, {void Function(double)? onProgress, bool Function()? isCancelled}) async {
|
||||
downloaded.add(m.id);
|
||||
for (var i = 0; i < m.files.length; i++) {
|
||||
File(await pathOf(m, i))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('x');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('원어 판별', () {
|
||||
test('문자 체계', () {
|
||||
|
|
@ -177,6 +192,43 @@ void main() {
|
|||
expect(v.subtitles.where((s) => s.kind == SubtitleKind.external), hasLength(3));
|
||||
});
|
||||
|
||||
test('259: 번역이 확실할 때만 번역 모델을 먼저 받는다', () {
|
||||
final en = languageOf('en'), ko = languageOf('ko');
|
||||
final w = whisperModels.first;
|
||||
expect(AiOptions(targets: {en}, whisper: w).surelyNeedsTranslation, isFalse, reason: '자동 감지 + 한 언어: 감지한 뒤에');
|
||||
expect(AiOptions(targets: {en, ko}, whisper: w).surelyNeedsTranslation, isTrue);
|
||||
expect(AiOptions(source: en, targets: {en}, whisper: w).surelyNeedsTranslation, isFalse);
|
||||
expect(AiOptions(source: en, targets: {ko}, whisper: w).surelyNeedsTranslation, isTrue);
|
||||
});
|
||||
|
||||
for (final (target, wantDownload) in [('en', false), ('ko', true)]) {
|
||||
test('259: 자동 감지 + 만들 언어 $target 하나 - 감지한 원어 (영어) 와 ${wantDownload ? '달라 그때' : '같아'} 번역 모델을 ${wantDownload ? '받는다' : '받지 않는다'}', () async {
|
||||
final tool = ProcessMediaTool('ffmpeg', 'ffprobe');
|
||||
if (await tool.version() == null) return markTestSkipped('FFmpeg 없음');
|
||||
final dir = Directory.systemTemp.createTempSync('jj_ai259_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final video = p.join(dir.path, '강의.mp4');
|
||||
await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=160x120:rate=10',
|
||||
'-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', video]);
|
||||
final models = _CountingModels(p.join(dir.path, 'models'));
|
||||
final tr = _FakeTranslator();
|
||||
final c = AppController(PlatformServices(
|
||||
mediaTool: tool,
|
||||
storage: DesktopStorageService(),
|
||||
createRecognizer: () => _FakeRecognizer(),
|
||||
createTranslator: () => tr,
|
||||
models: models,
|
||||
));
|
||||
await c.init();
|
||||
await c.addVideos([video]);
|
||||
final v = c.videos.single;
|
||||
await c.generateAiSubtitles([v], AiOptions(targets: {languageOf(target)}, whisper: whisperModels.first));
|
||||
expect(v.status, JobStatus.ready, reason: v.message);
|
||||
expect(models.downloaded, [whisperModels.first.id, if (wantDownload) nllbModel.id]);
|
||||
expect(tr.calls, [if (wantDownload) 'eng_Latn>kor_Hang']);
|
||||
});
|
||||
}
|
||||
|
||||
test('있는 자막 번역: 영어 SRT → _ko.srt (MKV 목록에 추가, 덮어쓰지 않음) + 작업 대기열', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('jj_tr_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
|
|
|
|||
|
|
@ -97,6 +97,20 @@ class _Backend implements DownloadBackend {
|
|||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다
|
||||
class _EndTool extends _HoldTool {
|
||||
final args = <Object?, List<String>>{};
|
||||
@override
|
||||
Future<void> runFfmpeg(List<String> a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
|
||||
args[tag] = a;
|
||||
await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag);
|
||||
File(a.last).writeAsStringSync('NEW');
|
||||
}
|
||||
|
||||
/// [tag] 의 FFmpeg 를 성공으로 끝낸다
|
||||
void finish(Object tag) => runs[tag]!.complete();
|
||||
}
|
||||
|
||||
AppController _plain() =>
|
||||
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
|
||||
|
|
@ -704,6 +718,100 @@ void main() {
|
|||
d.dispose();
|
||||
});
|
||||
|
||||
test('262: 동영상이 끝나는 바로 그때 줄 ■ (지금 것만 취소) 가 와도 성공 그대로 - 줄 · 집계 · 파일', () async {
|
||||
final tool = _EndTool();
|
||||
final dir = Directory.systemTemp.createTempSync('jj_batch262_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final files = [for (final n in ['batch1', 'batch2', 'batch3']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
|
||||
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
|
||||
await c.init();
|
||||
c.settings
|
||||
..maxParallelJobs = 1
|
||||
..mkvExisting = 'overwrite';
|
||||
await c.addVideos(files);
|
||||
final [v1, v2, v3] = c.videos;
|
||||
File(outputMkvPath(v1.path))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('OLD');
|
||||
final building = c.buildVideos([v1, v2, v3]);
|
||||
Future<void> until(bool Function() ok) async {
|
||||
for (var i = 0; i < 300 && !ok(); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
await until(() => tool.runs.containsKey(v1));
|
||||
// FFmpeg 끝 (성공) 바로 뒤 · 결과 이름으로 바꾸기 전에 ■
|
||||
tool.finish(v1);
|
||||
expect(v1.status, JobStatus.running);
|
||||
c.cancelVideos([v1]);
|
||||
// 끝난 바로 뒤 같은 자리의 ⟳ 를 눌러도 (손가락이 ■ 를 누르려던 것) 지금 묶음에서는 바뀌지 않는다
|
||||
await until(() => v1.status == JobStatus.done);
|
||||
c.resetStatus(v1);
|
||||
expect(v1.status, JobStatus.done);
|
||||
for (final v in [v2, v3]) {
|
||||
await until(() => tool.runs.containsKey(v));
|
||||
tool.finish(v);
|
||||
}
|
||||
await building;
|
||||
expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.done, JobStatus.done], reason: '${v1.message}');
|
||||
expect(File(outputMkvPath(v1.path)).readAsStringSync(), 'NEW');
|
||||
final log = c.logs.join('\n');
|
||||
expect(log, contains('성공: batch1.mkv'));
|
||||
expect(log, contains('성공 3 · 취소 0 · 실패 0 / 전체 3'));
|
||||
// 묶음이 끝나면 ⟳ 로 다시 대기로 돌릴 수 있다
|
||||
c.resetStatus(v1);
|
||||
expect(v1.status, JobStatus.ready);
|
||||
});
|
||||
|
||||
testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final tool = _EndTool();
|
||||
final dir = Directory.systemTemp.createTempSync('jj_batch262w_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
|
||||
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
|
||||
late Future<void> building;
|
||||
await tester.runAsync(() async {
|
||||
await c.init();
|
||||
c.settings.maxParallelJobs = 1;
|
||||
await c.addVideos(files);
|
||||
building = c.buildVideos([...c.videos]);
|
||||
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
});
|
||||
final [a, b] = c.videos;
|
||||
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
|
||||
await tester.pump();
|
||||
final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소'));
|
||||
// a 가 끝나고 b 가 시작
|
||||
await tester.runAsync(() async {
|
||||
tool.finish(a);
|
||||
for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
});
|
||||
await tester.pump();
|
||||
expect(a.status, JobStatus.done);
|
||||
// ■ 를 누르려던 자리를 누른다
|
||||
await tester.tapAt(stopAt);
|
||||
await tester.pump();
|
||||
expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음');
|
||||
expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음');
|
||||
await tester.runAsync(() async {
|
||||
tool.finish(b);
|
||||
await building;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2'));
|
||||
// 누른 자리의 풍선 도움말 타이머 정리
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
});
|
||||
|
||||
testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async {
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,38 @@ void main() {
|
|||
expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'new');
|
||||
});
|
||||
|
||||
testWidgets('264 · 265: 폴더를 합쳐 옮긴 뒤 - 알림에 "원본에 남음", 펼쳐 둔 원본 폴더는 다시 읽어 옮겨진 파일이 보이지 않는다', (tester) async {
|
||||
final d = p.join(left, 'D');
|
||||
File(p.join(d, 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('old')
|
||||
..setLastModifiedSync(DateTime(2026, 1, 1));
|
||||
File(p.join(d, 'add.txt')).writeAsStringSync('a');
|
||||
File(p.join(right, 'D', 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('NEWER');
|
||||
c.settings.copyConflict = 'overwrite';
|
||||
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('D').evaluate().length >= 2);
|
||||
// 왼쪽 D 를 눌러 펼치고 (고른 것) 안의 add.txt 가 보이게
|
||||
await tester.runAsync(() => tester.tap(find.text('D').first));
|
||||
await settle(tester, () => find.text('add.txt').evaluate().isNotEmpty);
|
||||
expect(find.text('add.txt'), findsOneWidget);
|
||||
await tester.runAsync(() => tester.tap(find.text('이동').first));
|
||||
await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
||||
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '이동')));
|
||||
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty);
|
||||
expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(d, 'same.txt')).existsSync(), isTrue, reason: '받는 쪽이 더 새것이라 원본에 남음');
|
||||
expect(find.textContaining('1개는 원본에 남음 (건너뜀)'), findsOneWidget, reason: '264: 알림에도');
|
||||
expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음 (오른쪽 D 는 펼치지 않음)');
|
||||
expect(find.text('same.txt'), findsOneWidget);
|
||||
await calm(tester);
|
||||
});
|
||||
|
||||
testWidgets('48 보충: 처음 열면 아무것도 고르지 않은 상태 - 바로 [복사] 를 누르면 "복사할 항목을 고르세요" (폴더 통째로 복사하지 않음)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ void main() {
|
|||
// 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창)
|
||||
await tester.longPress(find.text('doc.txt'));
|
||||
await calm(tester);
|
||||
expect(find.text('다른 창으로 복사'), findsOneWidget);
|
||||
// 77: 복사 · 이동 · 이름 변경 · 삭제는 메뉴 위 아이콘 줄 (두 창이면 복사는 다른 창으로)
|
||||
expect(find.byTooltip('다른 창으로 복사'), findsOneWidget);
|
||||
expect(find.text('삭제'), findsWidgets);
|
||||
await act(tester, () => tester.tap(find.text('다른 앱으로 열기')));
|
||||
await settle(tester, () => shell.opened.length == 2);
|
||||
|
|
@ -193,7 +194,7 @@ void main() {
|
|||
// 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기]
|
||||
await tester.longPress(find.text('inner.txt'));
|
||||
await calm(tester);
|
||||
expect(find.text('다른 창으로 복사'), findsNothing);
|
||||
expect(find.byTooltip('다른 창으로 복사'), findsNothing);
|
||||
await tester.tap(find.text('복사').last);
|
||||
await calm(tester);
|
||||
await act(tester, () => tester.tap(find.text('..')));
|
||||
|
|
@ -410,6 +411,69 @@ void main() {
|
|||
expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt');
|
||||
});
|
||||
|
||||
testWidgets('77: 길게 누르기 메뉴 - 위 아이콘 줄 · 여럿이면 "n개 항목" (이름 변경은 하나일 때만) · 경로 복사 · 즐겨찾기에 추가 · 삭제는 표시한 것 모두', (tester) async {
|
||||
File(p.join(left, 'b.txt')).writeAsStringSync('b');
|
||||
String? clip;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.setData') clip = (call.arguments as Map)['text'] as String?;
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
await open(tester);
|
||||
await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty);
|
||||
|
||||
// 하나: 머리 없음 · 아이콘 줄 · 경로 복사 (실제 경로)
|
||||
await tester.longPress(find.text('doc.txt').first);
|
||||
await calm(tester);
|
||||
expect(find.textContaining('개 항목'), findsNothing);
|
||||
for (final t in ['다른 창으로 복사', '다른 창으로 이동', '이름 변경', '삭제']) {
|
||||
expect(find.byTooltip(t), findsWidgets, reason: t); // 이름 변경 · 삭제는 아래 버튼 줄에도
|
||||
}
|
||||
await tester.tap(find.text('경로 복사'));
|
||||
await calm(tester);
|
||||
expect(clip, p.join(left, 'doc.txt'));
|
||||
|
||||
// 여럿: "2개 항목" · 이름 변경은 눌리지 않음 · 경로 복사는 둘 다
|
||||
await act(tester, () => tester.tap(find.text('선택').first));
|
||||
await act(tester, () => tester.tap(markOf('doc.txt')));
|
||||
await act(tester, () => tester.tap(markOf('b.txt')));
|
||||
await tester.longPress(find.text('b.txt').first);
|
||||
await calm(tester);
|
||||
expect(find.text('2개 항목'), findsOneWidget);
|
||||
expect(find.byTooltip('2개 삭제'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('이름 변경').last);
|
||||
await calm(tester);
|
||||
expect(find.text('2개 항목'), findsOneWidget, reason: '이름 변경은 눌리지 않아 메뉴 그대로');
|
||||
await tester.tap(find.text('경로 복사 (2개)'));
|
||||
await calm(tester);
|
||||
expect(clip!.split('\n'), unorderedEquals([p.join(left, 'doc.txt'), p.join(left, 'b.txt')]));
|
||||
|
||||
// 삭제 (아이콘 줄) 는 표시한 것 모두 - 확인 창에서 취소
|
||||
await tester.longPress(find.text('b.txt').first);
|
||||
await calm(tester);
|
||||
await tester.tap(find.byTooltip('2개 삭제'));
|
||||
await calm(tester);
|
||||
expect(find.textContaining('2개 항목을'), findsOneWidget);
|
||||
await tester.tap(find.text('취소').last);
|
||||
await calm(tester);
|
||||
expect(File(p.join(left, 'b.txt')).existsSync(), isTrue);
|
||||
// 선택 끝 (아래 알림이 [선택 끝] 을 가릴 수 있어 Esc)
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await calm(tester);
|
||||
|
||||
// 폴더: 즐겨찾기에 추가 (넣은 뒤에는 메뉴에서 빠짐)
|
||||
await tester.longPress(find.text('sub').first);
|
||||
await calm(tester);
|
||||
await tester.tap(find.text('즐겨찾기에 추가'));
|
||||
await calm(tester);
|
||||
expect(c.settings.folderBookmarks.map((x) => x.path), contains(p.join(left, 'sub')));
|
||||
await tester.longPress(find.text('sub').first);
|
||||
await calm(tester);
|
||||
expect(find.text('즐겨찾기에 추가'), findsNothing);
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await calm(tester);
|
||||
});
|
||||
|
||||
testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async {
|
||||
await open(tester);
|
||||
final sub = p.join(left, 'sub');
|
||||
|
|
@ -525,6 +589,99 @@ void main() {
|
|||
expect(fileDateText(DateTime(2026, 10, 11, 9, 5)), '2026-10-11 09:05');
|
||||
});
|
||||
|
||||
testWidgets('76: 빠른 필터 - Ctrl+F · 글자 바로 치기 · 패턴, 모두 선택은 보이는 것만, Esc 로 닫으면 모두', (tester) async {
|
||||
for (final n in ['ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) {
|
||||
File(p.join(left, n)).writeAsStringSync('12345678');
|
||||
}
|
||||
await open(tester);
|
||||
await settle(tester, () => find.text('other.txt').evaluate().isNotEmpty);
|
||||
Iterable<String> names() => [
|
||||
for (final n in ['doc.txt', 'sub', 'ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt'])
|
||||
if (find.text(n).evaluate().isNotEmpty) n,
|
||||
];
|
||||
|
||||
// Ctrl+F → 칸 · "ep0"
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await calm(tester);
|
||||
expect(find.textContaining('이름에 든 글자로 좁히기'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).first, 'EP0');
|
||||
await tester.pump();
|
||||
expect(names(), ['ep01.mkv', 'ep02.mkv'], reason: '대소문자 무시');
|
||||
expect(find.text('2개 보임'), findsOneWidget);
|
||||
|
||||
// 모두 선택 · 반전은 보이는 것만
|
||||
await act(tester, () => tester.tap(find.text('선택').first));
|
||||
await tester.tap(find.text('모두 선택').first);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('2개 표시함'), findsOneWidget);
|
||||
await tester.tap(find.text('선택 끝').first);
|
||||
await tester.pump();
|
||||
|
||||
// 패턴 · 맞는 것 없음
|
||||
await tester.enterText(find.byType(TextField).first, '*.srt');
|
||||
await tester.pump();
|
||||
expect(names(), ['ep10.srt']);
|
||||
await tester.enterText(find.byType(TextField).first, 'zzz');
|
||||
await tester.pump();
|
||||
expect(find.text('맞는 것이 없습니다'), findsOneWidget);
|
||||
|
||||
// Esc → 닫고 모두
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await calm(tester);
|
||||
expect(find.textContaining('이름에 든 글자로 좁히기'), findsNothing);
|
||||
expect(names(), containsAll(['doc.txt', 'sub', 'ep01.mkv', 'other.txt']));
|
||||
|
||||
// 닫으면 키는 탐색기로 돌아온다 - 목록에서 글자를 바로 치면 그 글자로 열린다.
|
||||
// 사람처럼 줄을 누르고 곧바로 친다 (두 번 누르기를 기다리는 중에 줄 목록이 바뀜)
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('doc.txt').first);
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO, character: 'o');
|
||||
await calm(tester);
|
||||
expect(tester.widget<TextField>(find.byType(TextField).first).controller!.text, 'o');
|
||||
expect(names(), ['doc.txt', 'other.txt']);
|
||||
// 칸에서 더 치면 칸이 받는다 (두 번 들어가지 않음)
|
||||
await tester.enterText(find.byType(TextField).first, 'oth');
|
||||
await tester.pump();
|
||||
expect(names(), ['other.txt']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('76: 파일 줄을 누르고 곧바로 쳐서 그 줄이 사라지거나 다른 줄이 그 자리에 와도, 누른 것이 다른 항목에 들어가지 않는다', (tester) async {
|
||||
for (final n in ['a1.mkv', 'b2.mkv']) {
|
||||
File(p.join(left, n)).writeAsStringSync('x');
|
||||
}
|
||||
Directory(p.join(left, 'a_folder')).createSync();
|
||||
await open(tester);
|
||||
await settle(tester, () => find.text('b2.mkv').evaluate().isNotEmpty);
|
||||
// 누르기 = 선택 (두 번 = 실행) 모드: 파일 줄은 두 번 누르기를 기다린다
|
||||
await tester.tap(find.text('b2.mkv').first);
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
// "a" → b2.mkv 가 사라지고 그 자리에 폴더 · 다른 파일이 온다
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('b2.mkv'), findsNothing);
|
||||
expect(shell.opened, isEmpty, reason: '누른 것이 실행되지 않음');
|
||||
// 다른 항목이 고른 것 (강조) 이 되지 않음 - 폴더가 펼쳐지지도 않음
|
||||
expect(find.text('a_folder'), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await calm(tester);
|
||||
expect(find.text('b2.mkv'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('76: 빠른 필터 맞추기 - 빈 칸은 모두 · 글자는 이름 안 어디든 · * ? 는 이름 전체 패턴', () {
|
||||
expect(quickFilterMatch('Show.S01E02.mkv', ''), isTrue);
|
||||
expect(quickFilterMatch('Show.S01E02.mkv', 's01e'), isTrue);
|
||||
expect(quickFilterMatch('Show.S01E02.mkv', 'e03'), isFalse);
|
||||
expect(quickFilterMatch('Show.S01E02.mkv', '*.mkv'), isTrue);
|
||||
expect(quickFilterMatch('Show.S01E02.mkv', 'S01*'), isFalse, reason: '패턴은 이름 전체');
|
||||
});
|
||||
|
||||
test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () {
|
||||
final re = globToRegExp('*.srt; ep??.mkv');
|
||||
expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
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/net_guard.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/models.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/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
|
||||
/// 261: 폰 세로 (노트 10+ 412×869) 의 MKV 세부 정보 - "감지한 언어" 줄이 생겨도 [AI 자막 만들기] 가 밀려 가려지지 않는다
|
||||
void main() {
|
||||
for (final (detected, dns) in [(false, false), (true, false), (true, true)]) {
|
||||
testWidgets('261 · 266: 폰 세로 · 작업 기록 켬 · 감지한 언어 ${detected ? '있음' : '없음'}${dns ? ' · DNS 닿지 않음 줄' : ''} - [AI 자막 만들기] 가 스크롤 없이 보인다', (tester) async {
|
||||
tester.view.physicalSize = const Size(412, 869);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final c = AppController(PlatformServices(
|
||||
mediaTool: ProcessMediaTool('x', 'y'),
|
||||
storage: DesktopStorageService(),
|
||||
createRecognizer: () => throw UnimplementedError(),
|
||||
createTranslator: () => throw UnimplementedError(),
|
||||
));
|
||||
c.settings.showLog = true;
|
||||
c.settings.moveTargets = [MoveTarget('보관', r'D:\보관')];
|
||||
for (var i = 1; i <= 4; i++) {
|
||||
c.videos.add(VideoItem('D:/v/아주 긴 이름의 동영상 파일 $i.mp4'));
|
||||
}
|
||||
c.selected = c.videos.first..aiDetected = detected ? 'en' : null;
|
||||
// 266: DNS 가 닿지 않는 동안 작업 기록 위의 줄 (936 노트 10+ 에서 두 줄이 되어 버튼을 밀어냄)
|
||||
if (dns) {
|
||||
NetGuard.instance.failure.value = DnsFailure('192.0.2.1', '답이 없습니다', DateTime(2026, 10, 12, 8, 42));
|
||||
addTearDown(() => NetGuard.instance.failure.value = null);
|
||||
}
|
||||
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
final ai = find.text('AI 자막 만들기');
|
||||
// 화면 밖이면 ListView 가 아예 만들지 않는다
|
||||
expect(ai, findsOneWidget, reason: '세부 정보 칸 아래로 밀림');
|
||||
final view = tester.getRect(find.ancestor(of: ai, matching: find.byType(Scrollable)).first);
|
||||
final button = tester.getRect(ai);
|
||||
expect(button.bottom, lessThanOrEqualTo(view.bottom), reason: '버튼 $button · 세부 정보 칸 $view');
|
||||
if (dns) {
|
||||
// 한 줄 · 버튼도 그 줄에
|
||||
final line = tester.getRect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않음 (08:42부터)'));
|
||||
final sys = tester.getRect(find.text('시스템 DNS 로 바꾸기'));
|
||||
expect((sys.center.dy - line.center.dy).abs(), lessThan(4), reason: '버튼이 같은 줄');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ a = sys.argv[1:]
|
|||
d = a[a.index('-P') + 1]
|
||||
f = os.path.join(d, 'download [download].unknown_video')
|
||||
open(f, 'w').write('PK')
|
||||
print('[generic] Extracting URL: ' + a[-1], flush=True)
|
||||
print('[download] Destination: ' + f, flush=True)
|
||||
sys.stderr.write('ERROR: Postprocessing: Error opening input files: Invalid data found when processing input\n')
|
||||
sys.exit(1)
|
||||
|
|
@ -49,6 +50,30 @@ sys.exit(1)
|
|||
expect(File(p.join(out.path, 'download [download].unknown_video')).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('사이트 추출기 (youtube 등) 의 후처리 실패는 바꾸지 않는다 - 망가진 동영상을 "완료" 로 두지 않음', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('jj_plain_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final script = File(p.join(dir.path, 'fake_ytdlp.py'))
|
||||
..writeAsStringSync(r'''
|
||||
import os, sys
|
||||
a = sys.argv[1:]
|
||||
d = a[a.index('-P') + 1]
|
||||
f = os.path.join(d, 'clip [abc].f137.mp4')
|
||||
open(f, 'w').write('V')
|
||||
print('[youtube] Extracting URL: ' + a[-1], flush=True)
|
||||
print('[download] Destination: ' + f, flush=True)
|
||||
sys.stderr.write('ERROR: Postprocessing: Error opening input files: Invalid data found when processing input\n')
|
||||
sys.exit(1)
|
||||
''');
|
||||
final backend = YtDlpBackend(ytdlp: 'python', prefixArgs: [script.path], deno: '');
|
||||
final t = DownloadTask(
|
||||
id: '1', kind: DownloadKind.video, source: 'https://www.youtube.com/watch?v=abc&file=a.zip', dir: dir.path);
|
||||
await backend.start(t, () {});
|
||||
expect(t.state, DownloadState.failed);
|
||||
expect(t.error, contains('Postprocessing'));
|
||||
expect(File(p.join(dir.path, 'clip [abc].f137.mp4')).existsSync(), isTrue, reason: '이름도 바꾸지 않음');
|
||||
});
|
||||
|
||||
test('받은 파일이 없으면 그대로 실패', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('jj_plain_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
|
|
|
|||
Loading…
Reference in New Issue