업데이트: 올려 둔 모든 버전 중 골라 설치 (예전 버전으로 되돌리기) · Rsync 화면 · 모니터링 개선 · 두 번째 실행 충돌 수정
- 설정 [업데이트 · 버전 고르기]: GitHub 의 모든 릴리스 (지금 · 최신 · 시험판 표시, 바뀐 내용) 중 골라 설치. 새 버전으로 · 예전 정상 버전으로 되돌리기 · 다시 설치. 자동 확인 (하루 한 번) 은 지금처럼 최신만. - 버전을 바꾸기 전 지금 버전의 설정 (설정 · 동영상 목록 · 즐겨찾기 · 창 위치) 을 version_snapshots 에 보관 (자막 API 키 · 비밀번호는 넣지 않음). 그 버전으로 돌아오면 (lastRunVersion 이 다르면) 되살릴지 묻고 다시 시작. - Windows 되돌리기: 설치 스크립트가 Lib 를 그 버전과 똑같이 맞춤 (robocopy /MIR, 받은 파일 · 모델 폴더 제외). - Android 되돌리기: 낮은 버전은 위에 설치 불가 → APK · 설정을 Download/JJ_MKVMaker 에 저장 후 앱 지우기 창, 다시 설치한 앱이 시작할 때 저장해 둔 설정을 되살릴지 묻는다. - 두 번째 실행 (앱이 켜져 있을 때 다시 실행) 이 끝나며 coremessaging.dll 접근 위반으로 꺼지던 것: 바로 끝내기로. - Rsync 화면: 새 폴더를 만들면 그 줄로 스크롤하고 그 창의 원본 · 대상으로 바로 고름 (트리 위치 계산도 폴더만 기준으로). - 모니터링: 끝나지 않은 (실행 중 · 실패 · 취소) 작업도 lsync 로 이동 (실행 중이면 물어보고 멈춘 뒤), 진행 막대 위 = 지금 파일 (그 파일의 % · 전송 속도), 아래 = 폴더 전체 (rsync to-chk 기준 항목 수), 전송 속도 표시 (rsync -P · 현재 방식은 최근 3초) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
1ac48354af
commit
0cf7a9a7b7
|
|
@ -13,6 +13,8 @@
|
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<!-- 예전 버전으로 되돌릴 때 이 앱을 지우는 확인 창 (Android 는 낮은 버전을 위에 설치하지 못함) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||
<application
|
||||
android:label="JJ_MKVMaker"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ class MainActivity : FlutterActivity() {
|
|||
result.error("INSTALL", e.message ?: e.toString(), null)
|
||||
}
|
||||
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||
// 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다
|
||||
"uninstallSelf" -> {
|
||||
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
result.success(null)
|
||||
}
|
||||
"diskSpace" -> result.success(
|
||||
try {
|
||||
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"API 키 (필수, 무료)": "API key (required, free)",
|
||||
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)",
|
||||
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.",
|
||||
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android cannot install a lower version over the current app. So:\n1. Save the v{0} install file and the current settings (including the video list and bookmarks) to Download/JJ_MKVMaker.\n2. Uninstall this app (Android confirmation).\n3. Tap the APK in Download/JJ_MKVMaker in a file manager to install it.\n4. Versions with this feature ask at startup whether to restore the saved settings. Older versions need to be set up again.\n* Subtitle API keys and passwords are not saved, so enter them again.",
|
||||
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Press [Install] on the Android install screen. Settings, downloaded files and AI models stay as they are.\n(The first time, you may need to turn on \"Allow from this source\")",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
|
||||
"Chrome (내려받기)": "Chrome (download)",
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: part of Windows, nothing to install. If you sign in to YouTube here, downloads use that sign-in too.\nChrome: if chosen, about {0}MB is downloaded and it can be used after a restart (Google sign-in may be blocked).{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "Downloads and installs FFmpeg · yt-dlp · aria2 · Deno if missing",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg ready: {0}{1}",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "Choose any version published on GitHub and install it (update, or roll back to an earlier working version). The settings of the version you leave are kept and restored when you return to it.",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
|
||||
"HTML 파일로 내보내기": "Export to HTML file",
|
||||
"HTML 파일에서 가져오기": "Import from HTML file",
|
||||
|
|
@ -191,6 +193,7 @@
|
|||
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
||||
"{0} 외 {1}개": "{0} and {1} more",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
|
||||
"{0} 전체": "All of {0}",
|
||||
"{0} 추가": "Add {0}",
|
||||
"{0} 파일": "{0} file",
|
||||
"{0}. {1} (대기)": "{0}. {1} (waiting)",
|
||||
|
|
@ -262,6 +265,7 @@
|
|||
"고지 문서": "Notices",
|
||||
"고화질": "High quality",
|
||||
"골라서 시작": "Choose and start",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
|
||||
"관리 메뉴": "Manage menu",
|
||||
"광고 건너뛰는 중…": "Skipping ad…",
|
||||
"권한 허용 화면 열기": "Open the permission screen",
|
||||
|
|
@ -338,6 +342,8 @@
|
|||
"누르면 일정 바꾸기": "Tap to change the schedule",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "As soon as they finish, put them in the Make MKV video list and remove them from the download list (same as the checkbox on the download screen)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "When finished, automatically add to the Make MKV video list and remove from this download list (the downloaded file stays)",
|
||||
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "You came back to v{1} after using another version ({0}). That version may not know some of this version's settings.\nRestore the settings kept when you last used v{1} (including the video list and bookmarks)?",
|
||||
"다른 버전 고르기": "Choose another version",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser",
|
||||
"다른 앱에서 동영상을 열 때": "When opening a video from another app",
|
||||
"다른 앱에서 열기: {0}": "Opened from another app: {0}",
|
||||
|
|
@ -351,6 +357,7 @@
|
|||
"다른 창으로 이동": "Move to other pane",
|
||||
"다른 폴더로 이동": "Move to another folder",
|
||||
"다시 만들기 대기로": "Back to waiting for remake",
|
||||
"다시 설치": "Reinstall",
|
||||
"다시 세기": "Recount",
|
||||
"다시 시작": "Restart",
|
||||
"다시 켤 때 동기화": "Sync on restart",
|
||||
|
|
@ -422,6 +429,7 @@
|
|||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "Drag and drop videos to add them to the list",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)",
|
||||
"동작 중 · {0} 까지": "Running · until {0}",
|
||||
"되살리고 다시 시작": "Restore and restart",
|
||||
"되살리기": "Restore",
|
||||
"두 창": "Two panes",
|
||||
"두 창 배치": "Two-pane arrangement",
|
||||
|
|
@ -448,6 +456,7 @@
|
|||
"맞춤": "Synced",
|
||||
"매번 고르기": "Ask every time",
|
||||
"매일 종일": "Every day, all day",
|
||||
"멈추고 옮기기": "Stop and move",
|
||||
"멈춤": "Stopped",
|
||||
"멈춤 (이번 실행 동안)": "Pause (this session)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again",
|
||||
|
|
@ -507,6 +516,9 @@
|
|||
"백그라운드로": "To background",
|
||||
"백그라운드로 계속 (기본)": "Keep running in the background (default)",
|
||||
"백그라운드로 실행": "Run in background",
|
||||
"버전 고르기": "Choose version",
|
||||
"버전 목록을 읽을 수 없습니다 ({0})": "Cannot read the version list ({0})",
|
||||
"버전 목록을 읽을 수 없습니다: {0}": "Cannot read the version list: {0}",
|
||||
"버튼 구성": "Button layout",
|
||||
"번역 모델 내려받는 중 {0}%": "Downloading translation model {0}%",
|
||||
"번역 모델 내려받는 중: {0}": "Downloading translation model: {0}",
|
||||
|
|
@ -521,6 +533,7 @@
|
|||
"벵골어": "Bengali",
|
||||
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
|
||||
"변환": "Convert",
|
||||
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
|
||||
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
|
||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
||||
"복사": "Copy",
|
||||
|
|
@ -589,11 +602,16 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "Play selected files: the video you are viewing (check several in the list)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "Play selected files: the {0} checked videos in list order",
|
||||
"선택한 파일 재생{0}": "Play selected files{0}",
|
||||
"설정 되살리기": "Restore settings",
|
||||
"설정 반영 후 실행": "Apply and run",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "Turn on \"Allow from this source\" in the settings screen, then come back and tap [Continue install]. The downloaded file is used as is (no download again).",
|
||||
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "Settings restored. The app will close - please open it again.",
|
||||
"설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}",
|
||||
"설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}",
|
||||
"설치 계속": "Continue install",
|
||||
"설치 실패": "Install failed",
|
||||
"설치 준비 완료": "Ready to install",
|
||||
"설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.": "Install file: {0}\nNow uninstall the app, then tap this file in a file manager to install it.",
|
||||
"설치 허용 필요": "Permission needed to install",
|
||||
"설치 화면을 열 수 없습니다: {0}": "Cannot open the install screen: {0}",
|
||||
"설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.",
|
||||
|
|
@ -629,10 +647,12 @@
|
|||
"시작 · 웹 브라우저": "Startup · web browser",
|
||||
"시작 안 함 (모니터링에서 시작)": "Don't start (start from Monitor)",
|
||||
"시작: {0} → {1}\\{2}{3}": "Started: {0} → {1}\\{2}{3}",
|
||||
"시작하는 중…": "Starting…",
|
||||
"시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "Choose which syncs to start. The others stay paused for this session (start them in Monitor > lsync).",
|
||||
"시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)",
|
||||
"시작할 때마다 설정 창 보기": "Show the setup window at every start",
|
||||
"시즌": "Season",
|
||||
"시험판": "Pre-release",
|
||||
"실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}",
|
||||
"실시간 동기화 시작": "Start live sync",
|
||||
|
|
@ -673,8 +693,10 @@
|
|||
"앱 안 브라우저 (권장)": "In-app browser (recommended)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
|
||||
"앱 지우기": "Uninstall app",
|
||||
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
|
||||
"앱을 다시 켤 때 동기화": "Sync when the app restarts",
|
||||
"앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "The app was newly installed and has no settings. Restore the saved settings of v{0} (including the video list and bookmarks)?\n(Subtitle API keys and passwords were not saved - please enter them again)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
|
||||
|
|
@ -683,6 +705,7 @@
|
|||
"언어: 미지정": "Language: unspecified",
|
||||
"언어를 더하지 못했습니다: {0}": "Could not add the language: {0}",
|
||||
"업데이트": "Update",
|
||||
"업데이트 · 버전 고르기": "Update · choose version",
|
||||
"업데이트 · 설치하고 남은 파일": "Update · install leftovers",
|
||||
"업데이트 실패: {0}": "Update failed: {0}",
|
||||
"없음": "None",
|
||||
|
|
@ -706,10 +729,14 @@
|
|||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "e.g. 0 9-17 * * 1-5 (weekdays 9:00-17:59) · 0 0-5 * * * (daily 0-5). Minutes are ignored, 1-hour units",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
|
||||
"예전 버전": "an earlier version",
|
||||
"예전 버전 v{0} 으로 되돌리기": "Roll back to earlier version v{0}",
|
||||
"예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, 나중에 이 버전으로 다시 오면 되살릴 수 있습니다.": "This rolls back to an earlier version. The current version's settings (including the video list and bookmarks) are kept and can be restored when you come back to this version later.",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})",
|
||||
"오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "Old job log files (app.log.1 etc.). The current log is kept",
|
||||
"오른쪽 90°": "Right 90°",
|
||||
"오픈 소스 라이선스": "Open source licenses",
|
||||
"올려 둔 버전이 없습니다.": "No versions have been published.",
|
||||
"올린 이 {0}": "Uploaded by {0}",
|
||||
"옮겼습니다.": "moved.",
|
||||
"옮기는 중": "Moving",
|
||||
|
|
@ -779,10 +806,13 @@
|
|||
"음소거 (M)": "Mute (M)",
|
||||
"이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file",
|
||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
|
||||
"이 기기용 파일 없음": "No file for this device",
|
||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)",
|
||||
"이 버전 건너뛰기": "Skip this version",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "This version has no APK for Android.",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "This version has no zip for Windows.",
|
||||
"이 버전으로 되돌리기": "Roll back to this version",
|
||||
"이 버전으로 업데이트": "Update to this version",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)",
|
||||
|
|
@ -915,7 +945,10 @@
|
|||
"저장 위치": "Save locations",
|
||||
"저장: {0}": "Save: {0}",
|
||||
"저장하고 검색": "Save and search",
|
||||
"저장하고 계속": "Save and continue",
|
||||
"저장하지 못했습니다: {0}": "Could not save: {0}",
|
||||
"저장하지 않은 변경": "Unsaved changes",
|
||||
"저장했습니다": "Saved",
|
||||
"저장했습니다: {0}": "Saved: {0}",
|
||||
"저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)",
|
||||
"적용": "Apply",
|
||||
|
|
@ -976,11 +1009,16 @@
|
|||
"즐겨찾기 추가…": "Add bookmark…",
|
||||
"즐겨찾기 추가됨": "Bookmark added",
|
||||
"즐겨찾기 표시줄": "Bookmarks bar",
|
||||
"지금": "Current",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
|
||||
"지금 맞추기": "Sync now",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
|
||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "Kept the settings of the current version (v{0}): {1}",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
|
||||
"지금 설정 그대로": "Keep current settings",
|
||||
"지금 설치": "Install now",
|
||||
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "It is running now. Stop it and move to lsync (live sync)? lsync will sync the rest.",
|
||||
"지금 쓰는 버전: v{0}": "Current version: v{0}",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
|
||||
"지금: {0}": "Now: {0}",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
|
||||
|
|
@ -1021,6 +1059,7 @@
|
|||
"체크한 동영상 {0}개의 {1}": "{1} of the {0} checked videos",
|
||||
"최고 화질": "Best quality",
|
||||
"최소화하면 트레이로 (백그라운드 실행)": "Minimize to tray (run in background)",
|
||||
"최신": "Latest",
|
||||
"최신 버전 확인": "Check for updates",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "Cannot check the latest version ({0})",
|
||||
"최신 버전입니다 (v{0})": "You have the latest version (v{0})",
|
||||
|
|
@ -1114,6 +1153,7 @@
|
|||
"프로그램 직접 선택…": "Choose a program…",
|
||||
"프로그램 폴더": "Program folder",
|
||||
"프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.": "Quits the program, installs, then restarts automatically.",
|
||||
"프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, 받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.": "The program will close, roll back to the earlier version (v{0}) and restart. The program folder is made identical to that version; downloads and AI models stay. The settings of the current version (v{1}) are kept.",
|
||||
"프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면": "Screen shown when the program starts and when you press the JJ icon at the top left",
|
||||
"프로그램의 모든 글자를 고른 언어로 보여 줍니다. 목록에 없는 언어는 [언어 추가] 로 이 기기의 AI 번역 모델이 자동 번역해 넣습니다.": "Shows all of the program's text in the chosen language. Languages not in the list can be added with [Add language]; this device's AI translation model translates them automatically.",
|
||||
"프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다": "When the program is already running. The new window only plays, and closing it ends only that window",
|
||||
|
|
@ -1130,6 +1170,7 @@
|
|||
"한 창": "One pane",
|
||||
"한국어": "Korean",
|
||||
"한글": "Hangul",
|
||||
"항목 {0}/{1}": "Items {0}/{1}",
|
||||
"항목마다 따로 (기본)": "Each item separately (default)",
|
||||
"해제": "Unset",
|
||||
"허용": "Allow",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"API 키 (필수, 무료)": "API キー(必須・無料)",
|
||||
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)",
|
||||
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。",
|
||||
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android はバージョンの低いアプリを今のアプリの上にインストールできません。そこで:\n1. v{0} のインストールファイルと今の設定 (動画リスト · ブックマークを含む) を Download/JJ_MKVMaker に保存します。\n2. このアプリを削除します (Android の確認画面)。\n3. ファイルアプリで Download/JJ_MKVMaker の APK をタップしてインストールします。\n4. この機能があるバージョンなら起動時に保存した設定を復元するか尋ねます。それより古いバージョンは設定し直す必要があります。\n※ 字幕 API キー · パスワードは保存しないので再入力が必要です。",
|
||||
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
|
||||
"Chrome (내려받기)": "Chrome(ダウンロード)",
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows に含まれているので別途インストール不要です。ここで YouTube にログインすると、ダウンロードにもそのログインが使われます。\nChrome: 選ぶと約 {0}MB をダウンロードし、再起動後に使えます(Google ログインはブロックされることがあります)。{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "FFmpeg・yt-dlp・aria2・Deno がなければダウンロードしてインストールします",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg 準備完了: {0}{1}",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "GitHub に公開したすべてのバージョンから選んでインストールします (新しいバージョンへ · 以前の正常なバージョンへ戻す)。切り替える前のバージョンの設定は保管し、そのバージョンに戻ると復元します",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
|
||||
"HTML 파일로 내보내기": "HTML ファイルに書き出す",
|
||||
"HTML 파일에서 가져오기": "HTML ファイルから取り込む",
|
||||
|
|
@ -191,6 +193,7 @@
|
|||
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
||||
"{0} 외 {1}개": "{0} ほか {1} 件",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
|
||||
"{0} 전체": "{0} 全体",
|
||||
"{0} 추가": "{0} を追加",
|
||||
"{0} 파일": "{0} ファイル",
|
||||
"{0}. {1} (대기)": "{0}. {1}(待機)",
|
||||
|
|
@ -262,6 +265,7 @@
|
|||
"고지 문서": "告知文書",
|
||||
"고화질": "高画質",
|
||||
"골라서 시작": "選んで開始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
|
||||
"관리 메뉴": "管理メニュー",
|
||||
"광고 건너뛰는 중…": "広告をスキップ中…",
|
||||
"권한 허용 화면 열기": "権限の許可画面を開く",
|
||||
|
|
@ -338,6 +342,8 @@
|
|||
"누르면 일정 바꾸기": "タップで予定を変更",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "ダウンロードが終わり次第 MKV 作成の動画リストに入れ、ダウンロードリストからは外します(ダウンロード画面のチェックボックスと同じ)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "ダウンロードが終わると MKV 作成の動画リストに自動で入れ、このダウンロードリストからは外します(ダウンロードしたファイルはそのまま)",
|
||||
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "別のバージョン ({0}) を使ったあと v{1} に戻りました。そのバージョンはこのバージョンの設定の一部を知らない可能性があります。\nv{1} を最後に使ったときに保管した設定 (動画リスト · ブックマークを含む) に戻しますか?",
|
||||
"다른 버전 고르기": "別のバージョンを選ぶ",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む",
|
||||
"다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき",
|
||||
"다른 앱에서 열기: {0}": "他のアプリから開く: {0}",
|
||||
|
|
@ -351,6 +357,7 @@
|
|||
"다른 창으로 이동": "もう一方のペインへ移動",
|
||||
"다른 폴더로 이동": "他のフォルダーに移動",
|
||||
"다시 만들기 대기로": "作り直し待ちに戻す",
|
||||
"다시 설치": "再インストール",
|
||||
"다시 세기": "再カウント",
|
||||
"다시 시작": "再起動",
|
||||
"다시 켤 때 동기화": "再起動時の同期",
|
||||
|
|
@ -422,6 +429,7 @@
|
|||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "動画をドラッグ&ドロップするとリストに追加されます",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)",
|
||||
"동작 중 · {0} 까지": "動作中 · {0} まで",
|
||||
"되살리고 다시 시작": "復元して再起動",
|
||||
"되살리기": "元に戻す",
|
||||
"두 창": "2 ペイン",
|
||||
"두 창 배치": "2 ペインの配置",
|
||||
|
|
@ -448,6 +456,7 @@
|
|||
"맞춤": "同期済み",
|
||||
"매번 고르기": "毎回選ぶ",
|
||||
"매일 종일": "毎日 終日",
|
||||
"멈추고 옮기기": "止めて移す",
|
||||
"멈춤": "停止",
|
||||
"멈춤 (이번 실행 동안)": "停止 (今回の実行中)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します",
|
||||
|
|
@ -507,6 +516,9 @@
|
|||
"백그라운드로": "バックグラウンドへ",
|
||||
"백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)",
|
||||
"백그라운드로 실행": "バックグラウンドで実行",
|
||||
"버전 고르기": "バージョンを選ぶ",
|
||||
"버전 목록을 읽을 수 없습니다 ({0})": "バージョン一覧を読めません ({0})",
|
||||
"버전 목록을 읽을 수 없습니다: {0}": "バージョン一覧を読めません: {0}",
|
||||
"버튼 구성": "ボタン構成",
|
||||
"번역 모델 내려받는 중 {0}%": "翻訳モデルをダウンロード中 {0}%",
|
||||
"번역 모델 내려받는 중: {0}": "翻訳モデルをダウンロード中: {0}",
|
||||
|
|
@ -521,6 +533,7 @@
|
|||
"벵골어": "ベンガル語",
|
||||
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
|
||||
"변환": "変換",
|
||||
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
|
||||
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
|
||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
||||
"복사": "コピー",
|
||||
|
|
@ -589,11 +602,16 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "選択したファイルを再生: 今見ている動画(複数はリストでチェック)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "選択したファイルを再生: チェックした動画 {0}個をリスト順に続けて",
|
||||
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
|
||||
"설정 되살리기": "設定の復元",
|
||||
"설정 반영 후 실행": "反映して実行",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。",
|
||||
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "設定を復元しました。アプリを終了します - もう一度起動してください。",
|
||||
"설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}",
|
||||
"설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}",
|
||||
"설치 계속": "インストールを続ける",
|
||||
"설치 실패": "インストール失敗",
|
||||
"설치 준비 완료": "インストール準備完了",
|
||||
"설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.": "インストールファイル: {0}\nアプリを削除してから、ファイルアプリでこのファイルをタップしてインストールしてください。",
|
||||
"설치 허용 필요": "インストールの許可が必要",
|
||||
"설치 화면을 열 수 없습니다: {0}": "インストール画面を開けません: {0}",
|
||||
"설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。",
|
||||
|
|
@ -629,10 +647,12 @@
|
|||
"시작 · 웹 브라우저": "起動・ウェブブラウザー",
|
||||
"시작 안 함 (모니터링에서 시작)": "開始しない (モニタリングで開始)",
|
||||
"시작: {0} → {1}\\{2}{3}": "開始: {0} → {1}\\{2}{3}",
|
||||
"시작하는 중…": "開始中…",
|
||||
"시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "開始する同期を選んでください。選ばなかったものは今回の実行中は停止します (モニタリング > lsync で開始)。",
|
||||
"시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)",
|
||||
"시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示",
|
||||
"시즌": "シーズン",
|
||||
"시험판": "プレリリース",
|
||||
"실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}",
|
||||
"실시간 동기화 시작": "リアルタイム同期の開始",
|
||||
|
|
@ -673,8 +693,10 @@
|
|||
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
|
||||
"앱 지우기": "アプリを削除",
|
||||
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
|
||||
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
|
||||
"앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "アプリを新しくインストールしたため設定がありません。保存した v{0} の設定 (動画リスト · ブックマークを含む) を復元しますか?\n(字幕 API キー · パスワードは保存していないので再入力してください)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
|
||||
|
|
@ -683,6 +705,7 @@
|
|||
"언어: 미지정": "言語: 未指定",
|
||||
"언어를 더하지 못했습니다: {0}": "言語を追加できませんでした: {0}",
|
||||
"업데이트": "更新",
|
||||
"업데이트 · 버전 고르기": "更新 · バージョンを選ぶ",
|
||||
"업데이트 · 설치하고 남은 파일": "更新・インストールの残り",
|
||||
"업데이트 실패: {0}": "更新失敗: {0}",
|
||||
"없음": "なし",
|
||||
|
|
@ -706,10 +729,14 @@
|
|||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (平日 9 時~17 時 59 分) · 0 0-5 * * * (毎日 0~5 時)。分は無視し 1 時間単位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
|
||||
"예전 버전": "以前のバージョン",
|
||||
"예전 버전 v{0} 으로 되돌리기": "以前のバージョン v{0} に戻す",
|
||||
"예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, 나중에 이 버전으로 다시 오면 되살릴 수 있습니다.": "以前のバージョンに戻します。現在のバージョンの設定 (動画リスト · ブックマークを含む) は保管しておき、あとでこのバージョンに戻ると復元できます。",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})",
|
||||
"오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "古い作業記録ファイル(app.log.1 など)。現在の記録は残します",
|
||||
"오른쪽 90°": "右 90°",
|
||||
"오픈 소스 라이선스": "オープンソースライセンス",
|
||||
"올려 둔 버전이 없습니다.": "公開したバージョンがありません。",
|
||||
"올린 이 {0}": "投稿者 {0}",
|
||||
"옮겼습니다.": "移動しました。",
|
||||
"옮기는 중": "移動中",
|
||||
|
|
@ -779,10 +806,13 @@
|
|||
"음소거 (M)": "ミュート(M)",
|
||||
"이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます",
|
||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
||||
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",
|
||||
"이 버전 건너뛰기": "このバージョンをスキップ",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "このバージョンには Android 用 APK がありません。",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。",
|
||||
"이 버전으로 되돌리기": "このバージョンに戻す",
|
||||
"이 버전으로 업데이트": "このバージョンに更新",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
|
||||
|
|
@ -915,7 +945,10 @@
|
|||
"저장 위치": "保存先",
|
||||
"저장: {0}": "保存: {0}",
|
||||
"저장하고 검색": "保存して検索",
|
||||
"저장하고 계속": "保存して続行",
|
||||
"저장하지 못했습니다: {0}": "保存できませんでした: {0}",
|
||||
"저장하지 않은 변경": "未保存の変更",
|
||||
"저장했습니다": "保存しました",
|
||||
"저장했습니다: {0}": "保存しました: {0}",
|
||||
"저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)",
|
||||
"적용": "適用",
|
||||
|
|
@ -976,11 +1009,16 @@
|
|||
"즐겨찾기 추가…": "ブックマークを追加…",
|
||||
"즐겨찾기 추가됨": "ブックマークを追加しました",
|
||||
"즐겨찾기 표시줄": "ブックマークバー",
|
||||
"지금": "現在",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
|
||||
"지금 맞추기": "今すぐ同期",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
|
||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "現在のバージョン (v{0}) の設定を保管しました: {1}",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
|
||||
"지금 설정 그대로": "今の設定のまま",
|
||||
"지금 설치": "今すぐインストール",
|
||||
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "実行中です。止めて lsync (リアルタイム同期) へ移しますか? 残りは lsync が続けて同期します。",
|
||||
"지금 쓰는 버전: v{0}": "使用中のバージョン: v{0}",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
|
||||
"지금: {0}": "現在: {0}",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
|
||||
|
|
@ -1021,6 +1059,7 @@
|
|||
"체크한 동영상 {0}개의 {1}": "チェックした動画 {0}個の{1}",
|
||||
"최고 화질": "最高画質",
|
||||
"최소화하면 트레이로 (백그라운드 실행)": "最小化するとトレイへ(バックグラウンド実行)",
|
||||
"최신": "最新",
|
||||
"최신 버전 확인": "最新バージョンを確認",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "最新バージョンを確認できません({0})",
|
||||
"최신 버전입니다 (v{0})": "最新バージョンです(v{0})",
|
||||
|
|
@ -1114,6 +1153,7 @@
|
|||
"프로그램 직접 선택…": "プログラムを直接選択…",
|
||||
"프로그램 폴더": "プログラムフォルダー",
|
||||
"프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.": "プログラムを終了してインストールし、自動で再起動します。",
|
||||
"프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, 받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.": "プログラムを終了し、以前のバージョン (v{0}) に戻してから再起動します。プログラムフォルダーはそのバージョンと同じにし、ダウンロードしたファイル · AI モデルはそのまま残します。現在のバージョン (v{1}) の設定は保管します。",
|
||||
"프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면": "プログラムを起動したとき、および左上の JJ アイコンを押したときに表示する画面",
|
||||
"프로그램의 모든 글자를 고른 언어로 보여 줍니다. 목록에 없는 언어는 [언어 추가] 로 이 기기의 AI 번역 모델이 자동 번역해 넣습니다.": "プログラムのすべての文字を選んだ言語で表示します。リストにない言語は[言語を追加]で、この端末の AI 翻訳モデルが自動翻訳して追加します。",
|
||||
"프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다": "プログラムが既に起動しているとき。新しいウィンドウは再生専用で、閉じるとそのウィンドウだけ終了します",
|
||||
|
|
@ -1130,6 +1170,7 @@
|
|||
"한 창": "1 ペイン",
|
||||
"한국어": "韓国語",
|
||||
"한글": "ハングル",
|
||||
"항목 {0}/{1}": "項目 {0}/{1}",
|
||||
"항목마다 따로 (기본)": "項目ごとに (既定)",
|
||||
"해제": "解除",
|
||||
"허용": "許可",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"API 키 (필수, 무료)": "API 密钥(必填,免费)",
|
||||
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)",
|
||||
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。",
|
||||
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android 无法在当前应用上安装版本更低的应用。因此:\n1. 将 v{0} 安装文件和当前设置 (包括视频列表 · 书签) 保存到 Download/JJ_MKVMaker。\n2. 卸载此应用 (Android 确认窗口)。\n3. 在文件应用中点按 Download/JJ_MKVMaker 中的 APK 进行安装。\n4. 带有此功能的版本会在启动时询问是否恢复保存的设置。更早的版本需要重新设置。\n※ 字幕 API 密钥 · 密码不会保存,需要重新输入。",
|
||||
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)",
|
||||
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
|
||||
"Chrome (내려받기)": "Chrome(下载)",
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows 自带,无需另外安装。在此登录 YouTube 后,下载也会使用该登录。\nChrome: 选择后会下载约 {0}MB,重新启动后可用(Google 登录可能被阻止)。{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "如缺少 FFmpeg · yt-dlp · aria2 · Deno,将下载并安装",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg 已就绪: {0}{1}",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "从 GitHub 上发布的所有版本中选择安装 (更新 · 回退到以前正常的版本)。切换前版本的设置会被保存,回到该版本时恢复",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
|
||||
"HTML 파일로 내보내기": "导出为 HTML 文件",
|
||||
"HTML 파일에서 가져오기": "从 HTML 文件导入",
|
||||
|
|
@ -191,6 +193,7 @@
|
|||
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
||||
"{0} 외 {1}개": "{0} 等 {1} 个",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
|
||||
"{0} 전체": "{0} 全部",
|
||||
"{0} 추가": "添加 {0}",
|
||||
"{0} 파일": "{0} 文件",
|
||||
"{0}. {1} (대기)": "{0}. {1}(等待)",
|
||||
|
|
@ -262,6 +265,7 @@
|
|||
"고지 문서": "声明文件",
|
||||
"고화질": "高画质",
|
||||
"골라서 시작": "选择后开始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
|
||||
"관리 메뉴": "管理菜单",
|
||||
"광고 건너뛰는 중…": "正在跳过广告…",
|
||||
"권한 허용 화면 열기": "打开权限授予界面",
|
||||
|
|
@ -338,6 +342,8 @@
|
|||
"누르면 일정 바꾸기": "点按以更改计划",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "下载完成后立即放入制作 MKV 的视频列表,并从下载列表移除(与下载界面的复选框相同)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "下载完成后自动加入制作 MKV 的视频列表,并从此下载列表移除(已下载的文件保留)",
|
||||
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "使用其他版本 ({0}) 后回到了 v{1}。该版本可能不认识此版本的部分设置。\n要恢复最后一次使用 v{1} 时保存的设置 (包括视频列表 · 书签) 吗?",
|
||||
"다른 버전 고르기": "选择其他版本",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签",
|
||||
"다른 앱에서 동영상을 열 때": "从其他应用打开视频时",
|
||||
"다른 앱에서 열기: {0}": "从其他应用打开: {0}",
|
||||
|
|
@ -351,6 +357,7 @@
|
|||
"다른 창으로 이동": "移动到另一窗格",
|
||||
"다른 폴더로 이동": "移动到其他文件夹",
|
||||
"다시 만들기 대기로": "改回等待重新制作",
|
||||
"다시 설치": "重新安装",
|
||||
"다시 세기": "重新统计",
|
||||
"다시 시작": "重新启动",
|
||||
"다시 켤 때 동기화": "重新启动时同步",
|
||||
|
|
@ -422,6 +429,7 @@
|
|||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "拖放视频即可加入列表",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)",
|
||||
"동작 중 · {0} 까지": "运行中 · 直到 {0}",
|
||||
"되살리고 다시 시작": "恢复并重新启动",
|
||||
"되살리기": "恢复",
|
||||
"두 창": "双窗格",
|
||||
"두 창 배치": "双窗格排列",
|
||||
|
|
@ -448,6 +456,7 @@
|
|||
"맞춤": "已同步",
|
||||
"매번 고르기": "每次选择",
|
||||
"매일 종일": "每天 全天",
|
||||
"멈추고 옮기기": "停止并移动",
|
||||
"멈춤": "已停止",
|
||||
"멈춤 (이번 실행 동안)": "暂停 (本次运行期间)",
|
||||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步",
|
||||
|
|
@ -507,6 +516,9 @@
|
|||
"백그라운드로": "转到后台",
|
||||
"백그라운드로 계속 (기본)": "在后台继续(默认)",
|
||||
"백그라운드로 실행": "在后台运行",
|
||||
"버전 고르기": "选择版本",
|
||||
"버전 목록을 읽을 수 없습니다 ({0})": "无法读取版本列表 ({0})",
|
||||
"버전 목록을 읽을 수 없습니다: {0}": "无法读取版本列表: {0}",
|
||||
"버튼 구성": "按钮配置",
|
||||
"번역 모델 내려받는 중 {0}%": "正在下载翻译模型 {0}%",
|
||||
"번역 모델 내려받는 중: {0}": "正在下载翻译模型: {0}",
|
||||
|
|
@ -521,6 +533,7 @@
|
|||
"벵골어": "孟加拉语",
|
||||
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
|
||||
"변환": "转换",
|
||||
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
|
||||
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
|
||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
||||
"복사": "复制",
|
||||
|
|
@ -589,11 +602,16 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "播放所选文件: 当前查看的视频(多个请在列表中勾选)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "播放所选文件: 按列表顺序连续播放勾选的 {0} 个视频",
|
||||
"선택한 파일 재생{0}": "播放所选文件{0}",
|
||||
"설정 되살리기": "恢复设置",
|
||||
"설정 반영 후 실행": "应用后运行",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。",
|
||||
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "设置已恢复。应用将关闭 - 请重新打开。",
|
||||
"설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}",
|
||||
"설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}",
|
||||
"설치 계속": "继续安装",
|
||||
"설치 실패": "安装失败",
|
||||
"설치 준비 완료": "准备安装",
|
||||
"설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.": "安装文件: {0}\n请先卸载应用,然后在文件应用中点按此文件进行安装。",
|
||||
"설치 허용 필요": "需要安装权限",
|
||||
"설치 화면을 열 수 없습니다: {0}": "无法打开安装界面: {0}",
|
||||
"설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。",
|
||||
|
|
@ -629,10 +647,12 @@
|
|||
"시작 · 웹 브라우저": "启动 · 网页浏览器",
|
||||
"시작 안 함 (모니터링에서 시작)": "不开始 (在监控中开始)",
|
||||
"시작: {0} → {1}\\{2}{3}": "开始: {0} → {1}\\{2}{3}",
|
||||
"시작하는 중…": "正在开始…",
|
||||
"시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "请选择要开始的同步。未选的在本次运行期间暂停 (可在 监控 > lsync 中开始)。",
|
||||
"시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)",
|
||||
"시작할 때마다 설정 창 보기": "每次启动时显示设置窗口",
|
||||
"시즌": "季",
|
||||
"시험판": "预发布",
|
||||
"실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}",
|
||||
"실시간 동기화 시작": "开始实时同步",
|
||||
|
|
@ -673,8 +693,10 @@
|
|||
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
|
||||
"앱 지우기": "卸载应用",
|
||||
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
|
||||
"앱을 다시 켤 때 동기화": "应用重新启动时同步",
|
||||
"앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "应用为全新安装,没有设置。要恢复保存的 v{0} 设置 (包括视频列表 · 书签) 吗?\n(字幕 API 密钥 · 密码未保存,请重新输入)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
|
||||
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
|
||||
|
|
@ -683,6 +705,7 @@
|
|||
"언어: 미지정": "语言: 未指定",
|
||||
"언어를 더하지 못했습니다: {0}": "未能添加语言: {0}",
|
||||
"업데이트": "更新",
|
||||
"업데이트 · 버전 고르기": "更新 · 选择版本",
|
||||
"업데이트 · 설치하고 남은 파일": "更新 · 安装残留",
|
||||
"업데이트 실패: {0}": "更新失败: {0}",
|
||||
"없음": "无",
|
||||
|
|
@ -706,10 +729,14 @@
|
|||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (工作日 9:00~17:59) · 0 0-5 * * * (每天 0~5 点)。忽略分钟,以 1 小时为单位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
|
||||
"예전 버전": "以前的版本",
|
||||
"예전 버전 v{0} 으로 되돌리기": "回退到以前的版本 v{0}",
|
||||
"예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, 나중에 이 버전으로 다시 오면 되살릴 수 있습니다.": "将回退到以前的版本。当前版本的设置 (包括视频列表 · 书签) 会被保存,以后回到此版本时可以恢复。",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})",
|
||||
"오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "旧的任务记录文件(app.log.1 等)。当前记录会保留",
|
||||
"오른쪽 90°": "向右 90°",
|
||||
"오픈 소스 라이선스": "开源许可",
|
||||
"올려 둔 버전이 없습니다.": "没有已发布的版本。",
|
||||
"올린 이 {0}": "上传者 {0}",
|
||||
"옮겼습니다.": "已移动。",
|
||||
"옮기는 중": "正在移动",
|
||||
|
|
@ -779,10 +806,13 @@
|
|||
"음소거 (M)": "静音(M)",
|
||||
"이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中",
|
||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
||||
"이 기기용 파일 없음": "没有此设备的文件",
|
||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",
|
||||
"이 버전 건너뛰기": "跳过此版本",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "此版本没有 Android 版 APK。",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。",
|
||||
"이 버전으로 되돌리기": "回退到此版本",
|
||||
"이 버전으로 업데이트": "更新到此版本",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
|
||||
|
|
@ -915,7 +945,10 @@
|
|||
"저장 위치": "保存位置",
|
||||
"저장: {0}": "保存: {0}",
|
||||
"저장하고 검색": "保存并搜索",
|
||||
"저장하고 계속": "保存并继续",
|
||||
"저장하지 못했습니다: {0}": "无法保存: {0}",
|
||||
"저장하지 않은 변경": "未保存的更改",
|
||||
"저장했습니다": "已保存",
|
||||
"저장했습니다: {0}": "已保存: {0}",
|
||||
"저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)",
|
||||
"적용": "应用",
|
||||
|
|
@ -976,11 +1009,16 @@
|
|||
"즐겨찾기 추가…": "添加书签…",
|
||||
"즐겨찾기 추가됨": "已添加书签",
|
||||
"즐겨찾기 표시줄": "书签栏",
|
||||
"지금": "当前",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
|
||||
"지금 맞추기": "立即同步",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
|
||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "已保存当前版本 (v{0}) 的设置: {1}",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
|
||||
"지금 설정 그대로": "保持当前设置",
|
||||
"지금 설치": "立即安装",
|
||||
"지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "正在运行。停止并移到 lsync (实时同步) 吗? 剩余部分由 lsync 继续同步。",
|
||||
"지금 쓰는 버전: v{0}": "当前版本: v{0}",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
|
||||
"지금: {0}": "当前: {0}",
|
||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
|
||||
|
|
@ -1021,6 +1059,7 @@
|
|||
"체크한 동영상 {0}개의 {1}": "勾选的 {0} 个视频的{1}",
|
||||
"최고 화질": "最高画质",
|
||||
"최소화하면 트레이로 (백그라운드 실행)": "最小化到托盘(后台运行)",
|
||||
"최신": "最新",
|
||||
"최신 버전 확인": "检查最新版本",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "无法检查最新版本({0})",
|
||||
"최신 버전입니다 (v{0})": "已是最新版本(v{0})",
|
||||
|
|
@ -1114,6 +1153,7 @@
|
|||
"프로그램 직접 선택…": "手动选择程序…",
|
||||
"프로그램 폴더": "程序文件夹",
|
||||
"프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.": "将退出程序、安装,然后自动重新启动。",
|
||||
"프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, 받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.": "程序将关闭,回退到以前的版本 (v{0}) 后重新启动。程序文件夹会与该版本完全一致,已下载的文件 · AI 模型保留不变。当前版本 (v{1}) 的设置会被保存。",
|
||||
"프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면": "程序启动时以及点击左上角 JJ 图标时显示的界面",
|
||||
"프로그램의 모든 글자를 고른 언어로 보여 줍니다. 목록에 없는 언어는 [언어 추가] 로 이 기기의 AI 번역 모델이 자동 번역해 넣습니다.": "以所选语言显示程序的所有文字。列表中没有的语言可通过[添加语言],由此设备的 AI 翻译模型自动翻译添加。",
|
||||
"프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다": "程序已在运行时。新窗口仅用于播放,关闭后只结束该窗口",
|
||||
|
|
@ -1130,6 +1170,7 @@
|
|||
"한 창": "单窗格",
|
||||
"한국어": "韩语",
|
||||
"한글": "韩文",
|
||||
"항목 {0}/{1}": "项目 {0}/{1}",
|
||||
"항목마다 따로 (기본)": "逐个项目 (默认)",
|
||||
"해제": "解除",
|
||||
"허용": "允许",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,10 @@ class AppSettings {
|
|||
/// [lastUpdateCheck] 를 한 버전 (같은 설정 파일을 쓰는 다른 버전이 확인했으면 다시 확인)
|
||||
String lastUpdateCheckVersion = '';
|
||||
|
||||
/// 마지막으로 이 설정을 쓴 (실행한) 버전. 예전 버전은 이 항목을 몰라 저장하면서 지운다 →
|
||||
/// 돌아온 버전이 자기 것이 아니면 보관해 둔 그 버전의 설정을 되살릴지 묻는다 (VersionSnapshot)
|
||||
String lastRunVersion = '';
|
||||
|
||||
/// "이 버전 건너뛰기" 한 버전
|
||||
String skippedVersion = '';
|
||||
|
||||
|
|
@ -451,6 +455,7 @@ class AppSettings {
|
|||
'autoCheckUpdates': autoCheckUpdates,
|
||||
'lastUpdateCheck': lastUpdateCheck,
|
||||
'lastUpdateCheckVersion': lastUpdateCheckVersion,
|
||||
'lastRunVersion': lastRunVersion,
|
||||
'skippedVersion': skippedVersion,
|
||||
'playlistMode': playlistMode.name,
|
||||
'externalPlayers': externalPlayers,
|
||||
|
|
@ -561,6 +566,7 @@ class AppSettings {
|
|||
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
|
||||
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
|
||||
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''
|
||||
..lastRunVersion = j['lastRunVersion'] as String? ?? ''
|
||||
..skippedVersion = j['skippedVersion'] as String? ?? ''
|
||||
..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
|
||||
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,26 @@ class TransferJob extends ChangeNotifier {
|
|||
this.prune = '',
|
||||
});
|
||||
|
||||
/// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다)
|
||||
String currentFile = '';
|
||||
|
||||
/// 지금 전송 속도 (바이트/초, 모르면 null): rsync 는 -P 가 알려 주는 값, 현재 방식은 최근 3초 동안 복사한 양
|
||||
double? speed;
|
||||
int _bytes = 0;
|
||||
final _samples = <(DateTime, int)>[];
|
||||
|
||||
void _addBytes(int n) {
|
||||
final now = DateTime.now();
|
||||
_bytes += n;
|
||||
_samples
|
||||
..add((now, _bytes))
|
||||
..removeWhere((x) => now.difference(x.$1).inMilliseconds > 3000);
|
||||
if (_samples.length >= 2) {
|
||||
final ms = now.difference(_samples.first.$1).inMilliseconds;
|
||||
if (ms > 0) speed = (_bytes - _samples.first.$2) * 1000 / ms;
|
||||
}
|
||||
}
|
||||
|
||||
/// 원본 정리 중 · 지운 빈 폴더 수
|
||||
bool pruning = false;
|
||||
int pruned = 0;
|
||||
|
|
@ -68,6 +88,16 @@ class TransferJob extends ChangeNotifier {
|
|||
/// 마지막 출력 몇 줄 (실패했을 때 보여 줌)
|
||||
final log = <String>[];
|
||||
|
||||
/// rsync 가 알려 주는 폴더 전체 확인 (to-chk) - 바뀐 파일만 이름이 나오므로 파일 수보다 정확하다. 모르면 0.
|
||||
int checked = 0, checkTotal = 0;
|
||||
|
||||
/// 폴더 전체 진행률 (rsync 는 to-chk, 그 밖은 [overall])
|
||||
double get folderProgress {
|
||||
if (finished) return 1;
|
||||
if (checkTotal > 0) return (checked / checkTotal).clamp(0.0, 1.0);
|
||||
return overall;
|
||||
}
|
||||
|
||||
int get allFiles => filesTotal.fold(0, (a, b) => a + b);
|
||||
int get allDone => filesDone.fold(0, (a, b) => a + b);
|
||||
|
||||
|
|
@ -144,6 +174,7 @@ class TransferJob extends ChangeNotifier {
|
|||
error = cancelled ? const FileOpCancelled() : e;
|
||||
} finally {
|
||||
finished = true;
|
||||
speed = null;
|
||||
_tick();
|
||||
if (!_done.isCompleted) _done.complete();
|
||||
}
|
||||
|
|
@ -153,8 +184,12 @@ class TransferJob extends ChangeNotifier {
|
|||
for (index = 0; index < sources.length; index++) {
|
||||
if (cancelled) return;
|
||||
final i = index;
|
||||
_ops = FileOps(bandwidthKBps: bandwidthKBps, onFileDone: (_) {
|
||||
_ops = FileOps(bandwidthKBps: bandwidthKBps, onFileDone: (src) {
|
||||
filesDone[i]++;
|
||||
currentFile = p.basename(src);
|
||||
// 옮긴 (이름 바꾼) 파일은 원본이 없어 0
|
||||
final st = FileStat.statSync(src);
|
||||
_addBytes(st.type == FileSystemEntityType.file ? st.size : 0);
|
||||
_tick();
|
||||
});
|
||||
_tick();
|
||||
|
|
@ -212,6 +247,7 @@ class TransferJob extends ChangeNotifier {
|
|||
(chunk) {
|
||||
for (final f in out.feed(chunk)) {
|
||||
_addLog(f);
|
||||
currentFile = f;
|
||||
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 (안의 것 모드는 이름이 없어 지금 항목)
|
||||
final first = f.split('/').first;
|
||||
var k = contents ? -1 : idx.indexWhere((i) => p.basename(sources[i]) == first);
|
||||
|
|
@ -221,6 +257,11 @@ class TransferJob extends ChangeNotifier {
|
|||
filesDone[at]++;
|
||||
}
|
||||
filePercent = out.currentPercent;
|
||||
speed = out.currentSpeed ?? speed;
|
||||
if (out.checkTotal != null) {
|
||||
checkTotal = out.checkTotal!;
|
||||
checked = out.checked!;
|
||||
}
|
||||
_tick();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../core/app_update.dart';
|
||||
|
||||
/// 버전을 바꿀 때 (업데이트 · 예전 버전으로 되돌리기) 그 버전의 설정을 보관하고, 그 버전으로 돌아오면 되살린다.
|
||||
///
|
||||
/// 예전 버전은 새 버전에만 있는 설정 항목을 몰라 저장하면서 지워 버릴 수 있다. 그래서 다른 버전을 설치하기 전에
|
||||
/// 지금 버전의 설정 파일들을 `version_snapshots/<버전>/` 에 두고, 다시 그 버전이 켜졌을 때
|
||||
/// (설정의 [lastRunVersion] 이 자기 버전이 아니면 = 그사이 다른 버전이 설정을 썼으면) 되살릴지 묻는다.
|
||||
/// 예전 버전의 업데이트 코드는 이 기능을 모르므로 되살리기는 돌아온 버전이 시작할 때 스스로 한다.
|
||||
///
|
||||
/// 비밀 값 (API 키 · 비밀번호) 은 보관본에 넣지 않는다. 되살릴 때는 지금 설정의 비밀 값을 그대로 둔다.
|
||||
class VersionSnapshot {
|
||||
/// 앱 데이터 폴더 (settings.json 등이 있는 곳)
|
||||
final String dataDir;
|
||||
|
||||
/// Android: 앱을 지웠다 다시 설치해도 남는 공용 폴더 (Download/JJ_MKVMaker/설정 보관). 없으면 null.
|
||||
final String? sharedDir;
|
||||
|
||||
VersionSnapshot(this.dataDir, {this.sharedDir});
|
||||
|
||||
/// 앱에서 쓰는 것 (시작할 때 main 이 정한다)
|
||||
static VersionSnapshot? instance;
|
||||
|
||||
/// 보관하는 파일 (설정 · 동영상 목록 · 즐겨찾기 · 창 위치)
|
||||
static const files = ['settings.json', 'videos.json', 'bookmarks.json', 'window.json'];
|
||||
|
||||
/// 보관본에 넣지 않는 설정 항목 (사용자가 다른 곳에 두지 말라고 한 비밀 값)
|
||||
static const secretKeys = ['openSubtitlesKey', 'openSubtitlesUser', 'openSubtitlesPassword'];
|
||||
|
||||
String get root => p.join(dataDir, 'version_snapshots');
|
||||
String dirOf(String version) => p.join(root, version);
|
||||
|
||||
/// 이 버전의 보관본이 있는지
|
||||
bool has(String version) => File(p.join(dirOf(version), 'settings.json')).existsSync();
|
||||
|
||||
/// 지금 버전 ([version]) 의 설정을 보관한다 (같은 버전의 보관본은 새것으로). 공용 폴더에도 (있으면).
|
||||
Future<void> save(String version) async {
|
||||
for (final dir in [dirOf(version), if (sharedDir != null) p.join(sharedDir!, version)]) {
|
||||
final d = Directory(dir);
|
||||
if (await d.exists()) await d.delete(recursive: true);
|
||||
await d.create(recursive: true);
|
||||
for (final f in files) {
|
||||
final src = File(p.join(dataDir, f));
|
||||
if (!await src.exists()) continue;
|
||||
final dst = File(p.join(dir, f));
|
||||
if (f == 'settings.json') {
|
||||
await dst.writeAsString(_withoutSecrets(await src.readAsString()));
|
||||
} else {
|
||||
await src.copy(dst.path);
|
||||
}
|
||||
}
|
||||
await File(p.join(dir, 'snapshot.json'))
|
||||
.writeAsString(jsonEncode({'version': version, 'saved': DateTime.now().toIso8601String()}));
|
||||
}
|
||||
}
|
||||
|
||||
/// 보관본 [from] (폴더) 을 데이터 폴더로 되살린다. 비밀 값은 지금 설정의 것을 그대로 둔다.
|
||||
Future<void> restoreFrom(String from) async {
|
||||
final current = File(p.join(dataDir, 'settings.json'));
|
||||
Map<String, dynamic> secrets = {};
|
||||
if (await current.exists()) {
|
||||
try {
|
||||
final j = jsonDecode(await current.readAsString()) as Map<String, dynamic>;
|
||||
secrets = {for (final k in secretKeys) if (j[k] != null) k: j[k]};
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final f in files) {
|
||||
final src = File(p.join(from, f));
|
||||
if (!await src.exists()) continue;
|
||||
final dst = File(p.join(dataDir, f));
|
||||
if (f == 'settings.json') {
|
||||
final j = jsonDecode(await src.readAsString()) as Map<String, dynamic>;
|
||||
j.addAll(secrets);
|
||||
await dst.writeAsString(const JsonEncoder.withIndent(' ').convert(j));
|
||||
} else {
|
||||
await src.copy(dst.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱을 다시 설치해 설정이 없을 때 (Android 에서 예전 버전으로 되돌리면 앱을 지워야 함):
|
||||
/// 공용 폴더에 남겨 둔 보관본 중 가장 가까운 것 (같은 버전, 없으면 가장 최근) 의 폴더. 없으면 null.
|
||||
String? sharedFor(String version) {
|
||||
final s = sharedDir;
|
||||
if (s == null || !Directory(s).existsSync()) return null;
|
||||
final same = p.join(s, version);
|
||||
if (File(p.join(same, 'settings.json')).existsSync()) return same;
|
||||
final all = Directory(s)
|
||||
.listSync()
|
||||
.whereType<Directory>()
|
||||
.where((d) => File(p.join(d.path, 'settings.json')).existsSync())
|
||||
.toList()
|
||||
..sort((a, b) => compareVersions(p.basename(b.path), p.basename(a.path)));
|
||||
return all.isEmpty ? null : all.first.path;
|
||||
}
|
||||
|
||||
static String _withoutSecrets(String settingsJson) {
|
||||
try {
|
||||
final j = jsonDecode(settingsJson) as Map<String, dynamic>;
|
||||
for (final k in secretKeys) {
|
||||
j.remove(k);
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(j);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 시작할 때 되살릴지: 이 버전의 보관본이 있고, 마지막으로 설정을 쓴 버전이 이 버전이 아니면
|
||||
/// (예전 버전이 그사이 설정을 썼으면 [lastRunVersion] 이 다르거나 비어 있다).
|
||||
bool shouldOffer(String current, String lastRunVersion) => has(current) && lastRunVersion != current;
|
||||
}
|
||||
|
|
@ -46,6 +46,10 @@ class ReleaseInfo {
|
|||
/// GitHub 가 계산한 zip 의 SHA256 (소문자 16진수). 없으면 null
|
||||
final String? sha256;
|
||||
|
||||
/// 올린 때 (모르면 null) · 시험판인지
|
||||
final DateTime? published;
|
||||
final bool prerelease;
|
||||
|
||||
const ReleaseInfo({
|
||||
required this.version,
|
||||
required this.tag,
|
||||
|
|
@ -56,6 +60,8 @@ class ReleaseInfo {
|
|||
this.zipName,
|
||||
this.zipSize = 0,
|
||||
this.sha256,
|
||||
this.published,
|
||||
this.prerelease = false,
|
||||
});
|
||||
|
||||
bool isNewerThan(String current) => compareVersions(version, current) > 0;
|
||||
|
|
@ -67,8 +73,22 @@ const androidAssetPattern = r'android_arm64\.apk$';
|
|||
|
||||
/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null).
|
||||
/// [assetPattern]: 받을 설치 파일 (기본 Windows zip, Android 는 [androidAssetPattern]). zipUrl · zipName 등에 담는다.
|
||||
ReleaseInfo? parseLatestRelease(Map<String, dynamic> j, {String assetPattern = windowsAssetPattern}) {
|
||||
if (j['draft'] == true || j['prerelease'] == true) return null;
|
||||
ReleaseInfo? parseLatestRelease(Map<String, dynamic> j, {String assetPattern = windowsAssetPattern}) =>
|
||||
parseRelease(j, assetPattern: assetPattern, allowPrerelease: false);
|
||||
|
||||
/// GitHub API `releases` 목록 → 올려 둔 모든 버전 (초안 빼고, 새 버전이 앞). 버전 고르기 (예전 버전으로 되돌리기) 에 쓴다.
|
||||
List<ReleaseInfo> parseReleaseList(List<dynamic> list, {String assetPattern = windowsAssetPattern}) {
|
||||
final out = [
|
||||
for (final j in list)
|
||||
if (j is Map<String, dynamic>) parseRelease(j, assetPattern: assetPattern, allowPrerelease: true),
|
||||
].whereType<ReleaseInfo>().toList();
|
||||
out.sort((a, b) => compareVersions(b.version, a.version));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Release 하나 → ReleaseInfo (초안은 null, [allowPrerelease] 가 false 면 시험판도 null)
|
||||
ReleaseInfo? parseRelease(Map<String, dynamic> j, {String assetPattern = windowsAssetPattern, bool allowPrerelease = false}) {
|
||||
if (j['draft'] == true || (!allowPrerelease && j['prerelease'] == true)) return null;
|
||||
final tag = j['tag_name'] as String?;
|
||||
if (tag == null) return null;
|
||||
Map<String, dynamic>? zip;
|
||||
|
|
@ -100,6 +120,8 @@ ReleaseInfo? parseLatestRelease(Map<String, dynamic> j, {String assetPattern = w
|
|||
zipName: zip?['name'] as String?,
|
||||
zipSize: (zip?['size'] as num?)?.toInt() ?? 0,
|
||||
sha256: sha,
|
||||
published: DateTime.tryParse(j['published_at'] as String? ?? ''),
|
||||
prerelease: j['prerelease'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,15 @@ class RsyncOutput {
|
|||
/// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄)
|
||||
double? currentPercent;
|
||||
|
||||
/// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s")
|
||||
double? currentSpeed;
|
||||
|
||||
/// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null.
|
||||
int? checked, checkTotal;
|
||||
|
||||
static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)');
|
||||
static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s');
|
||||
|
||||
List<String> feed(String chunk) {
|
||||
_buf.write(chunk);
|
||||
final text = _buf.toString();
|
||||
|
|
@ -152,6 +161,16 @@ class RsyncOutput {
|
|||
if (line.startsWith(' ')) {
|
||||
final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line);
|
||||
if (m != null) currentPercent = int.parse(m[1]!) / 100;
|
||||
final ck = _chkRx.firstMatch(line);
|
||||
if (ck != null) {
|
||||
checkTotal = int.parse(ck[2]!);
|
||||
checked = checkTotal! - int.parse(ck[1]!);
|
||||
}
|
||||
final sp = _speedRx.firstMatch(line);
|
||||
if (sp != null) {
|
||||
const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024};
|
||||
currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.endsWith('/')) continue; // 폴더
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart';
|
|||
|
||||
import 'app/app_controller.dart';
|
||||
import 'app/live_sync.dart';
|
||||
import 'app/version_snapshot.dart';
|
||||
import 'app/bookmarks_controller.dart';
|
||||
import 'app/download_manager.dart';
|
||||
import 'app/settings.dart';
|
||||
|
|
@ -40,6 +41,7 @@ import 'ui/monitor_page.dart' show askLiveSyncStart;
|
|||
import 'ui/player_page.dart';
|
||||
import 'ui/setup_dialog.dart';
|
||||
import 'ui/update_dialog.dart';
|
||||
import 'ui/version_restore.dart';
|
||||
import 'ui/theme.dart';
|
||||
import 'ui/work_panel.dart';
|
||||
import 'l10n/tr.dart';
|
||||
|
|
@ -69,7 +71,8 @@ Future<void> main(List<String> args) async {
|
|||
if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) {
|
||||
// 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다
|
||||
if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir);
|
||||
exit(0);
|
||||
endProcessNow(); // exit(0) 은 막 뜬 엔진을 정리하다 coremessaging.dll 에서 꺼진다
|
||||
|
||||
}
|
||||
|
||||
final services = PlatformServices.create();
|
||||
|
|
@ -91,6 +94,9 @@ Future<void> main(List<String> args) async {
|
|||
String hm(DateTime t) => '${t.month}/${t.day} ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
controller.note(trf('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). ' '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.', [hm(crashed.$1), hm(crashed.$2)]));
|
||||
}
|
||||
// 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치)
|
||||
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
||||
VersionSnapshot.instance = VersionSnapshot(dataDir);
|
||||
await controller.init();
|
||||
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
||||
await findBundledRsync(); // 앱에 들어 있는 rsync
|
||||
|
|
@ -298,6 +304,11 @@ Future<void> main(List<String> args) async {
|
|||
if (nav != null) unawaited(ExplorerPage.open(nav, c: controller));
|
||||
}
|
||||
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
|
||||
// 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지
|
||||
final current = await _currentVersion(services);
|
||||
if (ctx.mounted) {
|
||||
await checkVersionRestore(ctx, controller, current: current, freshInstall: freshInstall, restart: services.shell.restart);
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
await checkRequiredTools(ctx, services.shell);
|
||||
final ctx2 = navigatorKey.currentContext;
|
||||
|
|
@ -362,6 +373,15 @@ extension on String {
|
|||
String ifEmptyText(String other) => isEmpty ? other : this;
|
||||
}
|
||||
|
||||
/// 지금 실행 중인 버전 (예: 2026.10.05_003). 모르면 ''.
|
||||
Future<String> _currentVersion(PlatformServices services) async {
|
||||
try {
|
||||
return await services.updater?.currentVersion() ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 종료 창에 보일 "바로 전 작업": 하던 작업, 없으면 마지막 작업 기록 (시각 표시는 뺀다)
|
||||
String lastWorkText(AppController c) {
|
||||
if (c.currentJob != null) return c.currentJob!;
|
||||
|
|
@ -377,6 +397,14 @@ Future<void> runAndroid(String dataDir) async {
|
|||
final logs = Directory(p.join(dataDir, 'Logs'))..createSync(recursive: true);
|
||||
final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log');
|
||||
controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
|
||||
// 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때)
|
||||
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
||||
String? sharedSnapshots;
|
||||
try {
|
||||
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot');
|
||||
if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관');
|
||||
} catch (_) {}
|
||||
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
|
||||
await controller.init();
|
||||
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
||||
await findBundledRsync(); // 앱에 들어 있는 rsync
|
||||
|
|
@ -463,6 +491,17 @@ Future<void> runAndroid(String dataDir) async {
|
|||
// 다시 켤 때 실시간 동기화를 골라서 시작
|
||||
final ctx0 = navigatorKey.currentContext;
|
||||
if (controller.settings.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
|
||||
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
|
||||
final current = await _currentVersion(services);
|
||||
final ctxV = navigatorKey.currentContext;
|
||||
if (ctxV != null && ctxV.mounted) {
|
||||
await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall,
|
||||
restart: () async {
|
||||
messengerKey.currentState?.showSnackBar(SnackBar(content: Text(tr('설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.'))));
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
await services.shell.quit();
|
||||
});
|
||||
}
|
||||
// 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음)
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller));
|
||||
|
|
@ -523,7 +562,7 @@ Future<void> runSecondWindow(List<String> files, String dataDir) async {
|
|||
await i18n.apply(controller.settings.uiLanguage, save: false);
|
||||
controller.settings = await SettingsStore().load();
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: () => exit(0)));
|
||||
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: endProcessNow));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
|
||||
unawaited(controller.addVideos(files, allowOutputFolder: true));
|
||||
|
|
|
|||
|
|
@ -54,6 +54,18 @@ class AndroidUpdater implements Updater {
|
|||
return parseLatestRelease(jsonDecode(body) as Map<String, dynamic>, assetPattern: androidAssetPattern);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ReleaseInfo>> releases() async {
|
||||
final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases?per_page=100'));
|
||||
req.headers.set('Accept', 'application/vnd.github+json');
|
||||
final res = await req.close();
|
||||
final body = await res.transform(utf8.decoder).join();
|
||||
if (res.statusCode != 200) {
|
||||
throw UpdateException(trf('버전 목록을 읽을 수 없습니다 ({0})', [res.statusCode]));
|
||||
}
|
||||
return parseReleaseList(jsonDecode(body) as List<dynamic>, assetPattern: androidAssetPattern);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> canInstall() async => true;
|
||||
|
||||
|
|
@ -100,6 +112,9 @@ class AndroidUpdater implements Updater {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uninstallSelf() => _ch.invokeMethod<void>('uninstallSelf');
|
||||
|
||||
@override
|
||||
Future<void> openPage(ReleaseInfo r) async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ class GitHubUpdater implements Updater {
|
|||
return parseLatestRelease(jsonDecode(body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ReleaseInfo>> releases() async {
|
||||
final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases?per_page=100'));
|
||||
req.headers.set('Accept', 'application/vnd.github+json');
|
||||
final res = await req.close();
|
||||
final body = await res.transform(utf8.decoder).join();
|
||||
if (res.statusCode != 200) {
|
||||
throw UpdateException(trf('버전 목록을 읽을 수 없습니다 ({0})', [res.statusCode]));
|
||||
}
|
||||
return parseReleaseList(jsonDecode(body) as List<dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> canInstall() async {
|
||||
try {
|
||||
|
|
@ -124,7 +136,17 @@ try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {}
|
|||
Start-Sleep -Milliseconds 800
|
||||
$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log'
|
||||
# keep downloads, models and user data
|
||||
robocopy $Source $Target /E /R:10 /W:1 /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NP /LOG:$log | Out-Null
|
||||
$lib = Join-Path $Source 'Lib'
|
||||
if (Test-Path $lib) {
|
||||
# Lib layout: make the program folder exactly this version (also when going back to an older version),
|
||||
# then copy the top files (launcher, readme). User folders are excluded (not copied, not deleted).
|
||||
robocopy $lib (Join-Path $Target 'Lib') /MIR /R:10 /W:1 /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NP /LOG:$log | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { Start-Process notepad.exe $log; exit 1 }
|
||||
robocopy $Source $Target /R:10 /W:1 /XD Lib /NFL /NDL /NP /LOG+:$log | Out-Null
|
||||
} else {
|
||||
# old single-folder layout: copy over
|
||||
robocopy $Source $Target /E /R:10 /W:1 /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NP /LOG:$log | Out-Null
|
||||
}
|
||||
if ($LASTEXITCODE -ge 8) { Start-Process notepad.exe $log; exit 1 }
|
||||
if (-not $NoRestart) { Start-Process -FilePath $Exe }
|
||||
exit 0
|
||||
|
|
@ -151,6 +173,9 @@ exit 0
|
|||
await runInstallScript(extractedDir, waitPid: pid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uninstallSelf() async {}
|
||||
|
||||
@override
|
||||
Future<void> openPage(ReleaseInfo r) =>
|
||||
Process.start('explorer.exe', [r.pageUrl], mode: ProcessStartMode.detached);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import 'dart:io';
|
|||
|
||||
import '../../core/playlist.dart';
|
||||
|
||||
/// 이 프로세스를 바로 끝낸다. Dart 의 exit() 는 엔진 · 창 · 플러그인 DLL 을 정리하는 중에 Windows 의
|
||||
/// coremessaging.dll 등에서 접근 위반 (0xc0000005) 으로 꺼지기도 한다 - 특히 막 뜬 두 번째 실행이 바로 끝날 때
|
||||
/// (재현: 앱이 켜져 있을 때 한 번 더 실행 → APPCRASH). 저장할 것이 없는 경우에만 쓴다.
|
||||
Never endProcessNow() {
|
||||
if (Platform.isWindows) Process.killPid(pid);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/// 프로그램을 하나만 실행하고, 나중에 실행된 것은 인수를 첫 번째 창에 넘기고 끝낸다.
|
||||
///
|
||||
/// 탐색기에서 파일 여러 개를 골라 "재생" 하면 파일마다 프로그램이 실행되므로,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ abstract class Updater {
|
|||
/// GitHub 최신 Release (없거나 읽을 수 없으면 null)
|
||||
Future<ReleaseInfo?> latest();
|
||||
|
||||
/// GitHub 에 올려 둔 모든 버전 (새 버전이 앞) - 예전 버전으로 되돌리기
|
||||
Future<List<ReleaseInfo>> releases();
|
||||
|
||||
/// 프로그램 폴더에 쓸 수 있어 자동 설치가 가능한지 (아니면 페이지 안내)
|
||||
Future<bool> canInstall();
|
||||
|
||||
|
|
@ -34,6 +37,9 @@ abstract class Updater {
|
|||
/// 브라우저로 Release 페이지 열기
|
||||
Future<void> openPage(ReleaseInfo r);
|
||||
|
||||
/// 이 앱을 지우는 확인 창 (Android: 예전 버전으로 되돌릴 때. Windows 는 하지 않음)
|
||||
Future<void> uninstallSelf();
|
||||
|
||||
/// true 면 [scheduleInstall] 이 설치 화면을 열 뿐 앱을 끝낼 필요가 없다 (Android)
|
||||
bool get installsInPlace => false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
void _reveal(_Pane pane, String path) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !pane.scroll.hasClients) return;
|
||||
final i = _rows(pane, foldersOnly: _split).indexWhere((r) => samePath(r.entry.path, path));
|
||||
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
|
||||
final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => samePath(r.entry.path, path));
|
||||
if (i < 0) return;
|
||||
final pos = pane.scroll.position;
|
||||
final want = (i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent);
|
||||
|
|
@ -659,10 +660,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final name = await _askName(tr('새 폴더'), '');
|
||||
if (name == null) return;
|
||||
try {
|
||||
final made = await FileOps.makeFolder(pane.current, name);
|
||||
await _refreshAll([pane.current]);
|
||||
final parent = pane.current;
|
||||
final made = await FileOps.makeFolder(parent, name);
|
||||
// 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤
|
||||
pane.expanded.add(parent);
|
||||
await _load(pane, parent, force: true);
|
||||
await _refreshAll([parent]);
|
||||
// Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다
|
||||
if (widget.rsync) {
|
||||
pane.marked
|
||||
..clear()
|
||||
..add(made);
|
||||
}
|
||||
pane.focused = made;
|
||||
pane.changed();
|
||||
_reveal(pane, made);
|
||||
} catch (e) {
|
||||
_snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
|
||||
}
|
||||
|
|
@ -1116,20 +1128,47 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
),
|
||||
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
|
||||
]),
|
||||
line(
|
||||
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
|
||||
job.overall,
|
||||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||||
),
|
||||
line(
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: trf('{0} 안의 파일', [job.currentName]),
|
||||
job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
|
||||
),
|
||||
// Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수)
|
||||
if (widget.rsync) ...[
|
||||
line(
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: job.finished
|
||||
? tr('끝')
|
||||
: job.currentFile.isEmpty
|
||||
? tr('시작하는 중…')
|
||||
: job.currentFile.split('/').last,
|
||||
job.finished ? 1.0 : (job.filePercent ?? 0),
|
||||
[
|
||||
if (job.filePercent != null && !job.finished) pct(job.filePercent!),
|
||||
if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s',
|
||||
].join(' · '),
|
||||
),
|
||||
line(
|
||||
trf('{0} 전체', [job.currentName.isEmpty ? p.basename(job.sources.first) : job.currentName]),
|
||||
job.folderProgress,
|
||||
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||||
),
|
||||
] else ...[
|
||||
// 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중
|
||||
line(
|
||||
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
|
||||
job.overall,
|
||||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'
|
||||
'${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}',
|
||||
),
|
||||
line(
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: trf('{0} 안의 파일', [job.currentName]),
|
||||
job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,17 +141,26 @@ class TransferProgressLines extends StatelessWidget {
|
|||
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim))),
|
||||
]),
|
||||
);
|
||||
final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : '';
|
||||
// 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수)
|
||||
final fileLabel = job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: job.finished
|
||||
? tr('끝')
|
||||
: job.currentFile.isEmpty
|
||||
? tr('시작하는 중…')
|
||||
: job.currentFile.split('/').last;
|
||||
final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0);
|
||||
return Column(children: [
|
||||
line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall,
|
||||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'),
|
||||
line(
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: trf('{0} 안의 파일', [p.basename(job.sources[cur])]),
|
||||
job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [job.filesDone[cur], job.filesTotal[cur]])}'),
|
||||
Tooltip(
|
||||
message: job.currentFile,
|
||||
child: line(fileLabel, fileValue,
|
||||
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
|
||||
),
|
||||
line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress,
|
||||
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
|
@ -290,7 +299,27 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
await _run(t);
|
||||
}
|
||||
|
||||
/// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다.
|
||||
/// 실행 중이면 물어본 뒤 멈추고 옮긴다.
|
||||
Future<void> _toLive(CopyTask t) async {
|
||||
if (center.isRunning(t.id)) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('lsync 로 이동')),
|
||||
content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('멈추고 옮기기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
final job = center.jobs[t.id]!;
|
||||
job.cancel();
|
||||
await job.done;
|
||||
if (!mounted) return;
|
||||
}
|
||||
final n = await center.toLiveSync(t);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
|
|
@ -419,7 +448,7 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton.icon(
|
||||
onPressed: running ? null : () => _toLive(t),
|
||||
onPressed: () => _toLive(t),
|
||||
icon: const Icon(Icons.sync_alt, size: 18),
|
||||
label: Text(tr('lsync 로 이동')),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -289,12 +289,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
future: c.services.updater!.currentVersion(),
|
||||
builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'),
|
||||
),
|
||||
subtitle: Text(tr('GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다')),
|
||||
trailing: FilledButton.icon(
|
||||
onPressed: () => checkForUpdate(context, c, manual: true),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(tr('최신 버전 확인')),
|
||||
),
|
||||
subtitle: Text(tr('GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). '
|
||||
'바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다')),
|
||||
isThreeLine: true,
|
||||
trailing: Wrap(spacing: 8, children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => checkForUpdate(context, c, manual: true),
|
||||
child: Text(tr('최신 버전 확인')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () => chooseVersion(context, c),
|
||||
icon: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(tr('업데이트 · 버전 고르기')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.autoCheckUpdates,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/version_snapshot.dart';
|
||||
import '../core/app_update.dart';
|
||||
import '../services/updater.dart' show InstallPermissionNeeded;
|
||||
import '../services/updater.dart' show InstallPermissionNeeded, Updater;
|
||||
import 'theme.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작
|
||||
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만)
|
||||
///
|
||||
/// [manual]: 환경 설정에서 "지금 확인" 을 누른 경우 (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
|
||||
/// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
|
||||
Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual = false}) async {
|
||||
final up = c.services.updater;
|
||||
if (up == null) return;
|
||||
|
|
@ -62,24 +66,14 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
|||
Text(trf('받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다', [(r.zipSize / 1e6).round()]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 12),
|
||||
Text(tr('바뀐 내용'), style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(r.notes.isEmpty ? tr('(설명 없음)') : r.notes,
|
||||
style: const TextStyle(fontSize: 12, height: 1.5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: _Notes(r.notes)),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: Text(tr('이 버전 건너뛰기'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, 'page'), child: Text(tr('페이지 열기'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, 'versions'), child: Text(tr('다른 버전 고르기'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
FilledButton.icon(
|
||||
onPressed: () => Navigator.pop(ctx, 'update'),
|
||||
|
|
@ -89,17 +83,215 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
|||
],
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
switch (choice) {
|
||||
case 'skip':
|
||||
await c.updateSettings((x) => x.skippedVersion = r!.version);
|
||||
return;
|
||||
case 'page':
|
||||
await up.openPage(r);
|
||||
return;
|
||||
case 'versions':
|
||||
await chooseVersion(context, c);
|
||||
case 'update':
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
await installRelease(context, c, r, current);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다.
|
||||
/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다.
|
||||
Future<void> chooseVersion(BuildContext context, AppController c) async {
|
||||
final up = c.services.updater;
|
||||
if (up == null) return;
|
||||
final String current;
|
||||
try {
|
||||
current = await up.currentVersion();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final picked = await showDialog<ReleaseInfo>(
|
||||
context: context,
|
||||
builder: (_) => _VersionPicker(up: up, current: current),
|
||||
);
|
||||
if (picked == null || !context.mounted) return;
|
||||
await installRelease(context, c, picked, current);
|
||||
}
|
||||
|
||||
/// 버전 고르기 창
|
||||
class _VersionPicker extends StatefulWidget {
|
||||
final Updater up;
|
||||
final String current;
|
||||
const _VersionPicker({required this.up, required this.current});
|
||||
|
||||
@override
|
||||
State<_VersionPicker> createState() => _VersionPickerState();
|
||||
}
|
||||
|
||||
class _VersionPickerState extends State<_VersionPicker> {
|
||||
late final Future<List<ReleaseInfo>> _list = widget.up.releases();
|
||||
ReleaseInfo? _sel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 처음엔 지금 쓰는 버전을 골라 둔다 (목록을 다 읽은 뒤 버튼 글도 맞게)
|
||||
_list.then((all) {
|
||||
if (!mounted || _sel != null || all.isEmpty) return;
|
||||
setState(() => _sel = all.firstWhere((r) => r.version == widget.current, orElse: () => all.first));
|
||||
}, onError: (_) {});
|
||||
}
|
||||
|
||||
String _date(DateTime? d) {
|
||||
if (d == null) return '';
|
||||
final l = d.toLocal();
|
||||
String two(int x) => x.toString().padLeft(2, '0');
|
||||
return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}';
|
||||
}
|
||||
|
||||
/// 고른 버전으로 무엇을 하게 되는지 (버튼 글)
|
||||
String _action(ReleaseInfo r) {
|
||||
final d = compareVersions(r.version, widget.current);
|
||||
return d > 0 ? tr('이 버전으로 업데이트') : d < 0 ? tr('이 버전으로 되돌리기') : tr('다시 설치');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: Text(tr('버전 고르기')),
|
||||
content: SizedBox(
|
||||
width: 720,
|
||||
height: 480,
|
||||
child: FutureBuilder<List<ReleaseInfo>>(
|
||||
future: _list,
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) return Center(child: Text(trf('버전 목록을 읽을 수 없습니다: {0}', [snap.error])));
|
||||
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
|
||||
final all = snap.data!;
|
||||
if (all.isEmpty) return Center(child: Text(tr('올려 둔 버전이 없습니다.')));
|
||||
final newest = all.first.version;
|
||||
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: Text(trf('지금 쓰는 버전: v{0}', [widget.current]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
for (final r in all)
|
||||
ListTile(
|
||||
dense: true,
|
||||
selected: _sel?.version == r.version,
|
||||
selectedTileColor: JjColors.accent.withValues(alpha: 0.15),
|
||||
title: Row(children: [
|
||||
Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (r.version == widget.current) _Badge(tr('지금'), JjColors.accent),
|
||||
if (r.version == newest) _Badge(tr('최신'), Colors.greenAccent),
|
||||
if (r.prerelease) _Badge(tr('시험판'), Colors.orangeAccent),
|
||||
]),
|
||||
subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published),
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
enabled: r.zipUrl != null,
|
||||
onTap: () => setState(() => _sel = r),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const VerticalDivider(width: 16),
|
||||
Expanded(
|
||||
child: _sel == null
|
||||
? const SizedBox()
|
||||
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('v${_sel!.version}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
if (compareVersions(_sel!.version, widget.current) < 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
tr('예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, '
|
||||
'나중에 이 버전으로 다시 오면 되살릴 수 있습니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _Notes(_sel!.notes)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))),
|
||||
FilledButton.icon(
|
||||
onPressed: _sel == null || _sel!.zipUrl == null ? null : () => Navigator.pop(context, _sel),
|
||||
icon: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(_sel == null ? tr('업데이트') : _action(_sel!)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const _Badge(this.text, this.color);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(border: Border.all(color: color), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(text, style: TextStyle(fontSize: 10, color: color)),
|
||||
);
|
||||
}
|
||||
|
||||
class _Notes extends StatelessWidget {
|
||||
final String notes;
|
||||
const _Notes(this.notes);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(notes.isEmpty ? tr('(설명 없음)') : notes, style: const TextStyle(fontSize: 12, height: 1.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null.
|
||||
Future<String?> _download(BuildContext context, Updater up, ReleaseInfo r) async {
|
||||
final progress = ValueNotifier<double>(0);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(trf('v{0} 받는 중', [r.version])),
|
||||
content: ValueListenableBuilder<double>(
|
||||
valueListenable: progress,
|
||||
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: v >= 1 ? null : v),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final dir = await up.download(r, (x) => progress.value = x);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
return dir;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('업데이트 실패: {0}', [e]))));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [r] 버전 설치 (새 버전 · 예전 버전 · 같은 버전 모두). 설치 전에 지금 버전의 설정을 보관한다.
|
||||
Future<void> installRelease(BuildContext context, AppController c, ReleaseInfo r, String current) async {
|
||||
final up = c.services.updater!;
|
||||
void snack(String t) {
|
||||
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
|
||||
}
|
||||
|
||||
if (c.busy) {
|
||||
|
|
@ -112,40 +304,29 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
|||
await up.openPage(r);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
final progress = ValueNotifier<double>(0);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(trf('v{0} 받는 중', [r!.version])),
|
||||
content: ValueListenableBuilder<double>(
|
||||
valueListenable: progress,
|
||||
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: v >= 1 ? null : v),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
String dir;
|
||||
try {
|
||||
dir = await up.download(r, (x) => progress.value = x);
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
snack(trf('업데이트 실패: {0}', [e]));
|
||||
final down = compareVersions(r.version, current) < 0;
|
||||
// Android 는 낮은 버전을 위에 설치할 수 없다 (앱을 지우고 설치해야 함) → 따로 안내
|
||||
if (down && up.installsInPlace) {
|
||||
if (context.mounted) await _androidRollback(context, c, up, r, current);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
final dir = await _download(context, up, r);
|
||||
if (dir == null || !context.mounted) return;
|
||||
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('설치 준비 완료')),
|
||||
content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [r!.version, up.installsInPlace ? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)') : tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.')])),
|
||||
content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [
|
||||
r.version,
|
||||
up.installsInPlace
|
||||
? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)')
|
||||
: down
|
||||
? trf('프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, '
|
||||
'받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.', [r.version, current])
|
||||
: tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
|
||||
])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지금 설치'))),
|
||||
|
|
@ -153,6 +334,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
|||
),
|
||||
);
|
||||
if (go != true) return;
|
||||
await _saveSnapshot(c, current);
|
||||
// Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움)
|
||||
if (up.installsInPlace) {
|
||||
while (true) {
|
||||
|
|
@ -187,3 +369,76 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
|||
await up.scheduleInstall(dir);
|
||||
await c.services.shell.quit();
|
||||
}
|
||||
|
||||
/// 다른 버전을 설치하기 전에 지금 버전의 설정을 보관 (그 버전으로 돌아오면 되살린다)
|
||||
Future<void> _saveSnapshot(AppController c, String current) async {
|
||||
final snap = VersionSnapshot.instance;
|
||||
if (snap == null) return;
|
||||
try {
|
||||
await c.updateSettings((x) => x.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게
|
||||
await snap.save(current);
|
||||
c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)]));
|
||||
} catch (e) {
|
||||
c.note(trf('설정을 보관하지 못했습니다: {0}', [e]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다.
|
||||
/// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다.
|
||||
Future<void> _androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async {
|
||||
final snap = VersionSnapshot.instance;
|
||||
final shared = snap?.sharedDir;
|
||||
if (shared == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.'))));
|
||||
return;
|
||||
}
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: Text(trf(
|
||||
'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n'
|
||||
'1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n'
|
||||
'2. 이 앱을 지웁니다 (Android 확인 창).\n'
|
||||
'3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n'
|
||||
'4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n'
|
||||
'※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.',
|
||||
[r.version])),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
final apk = await _download(context, up, r);
|
||||
if (apk == null || !context.mounted) return;
|
||||
final outDir = p.dirname(shared);
|
||||
final saved = p.join(outDir, r.zipName ?? 'JJ_MKVMaker_v${r.version}.apk');
|
||||
try {
|
||||
await Directory(outDir).create(recursive: true);
|
||||
await File(apk).copy(saved);
|
||||
await _saveSnapshot(c, current);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e]))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('저장했습니다')),
|
||||
content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('앱 지우기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go == true) await up.uninstallSelf();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/version_snapshot.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 시작할 때: 다른 버전을 쓰다가 이 버전으로 돌아왔으면 (또는 Android 에서 앱을 다시 설치해 설정이 없으면)
|
||||
/// 보관해 둔 설정을 되살릴지 묻는다. 되살리면 [restart] (다시 시작) 를 부른다. 끝나면 [lastRunVersion] 을 이 버전으로.
|
||||
///
|
||||
/// [freshInstall]: 시작할 때 설정 파일이 없었다 (앱을 새로 설치함)
|
||||
Future<void> checkVersionRestore(
|
||||
BuildContext context,
|
||||
AppController c, {
|
||||
required String current,
|
||||
required bool freshInstall,
|
||||
required Future<void> Function() restart,
|
||||
}) async {
|
||||
final snap = VersionSnapshot.instance;
|
||||
if (snap == null || current.isEmpty) return;
|
||||
String? from;
|
||||
String? message;
|
||||
if (freshInstall) {
|
||||
// Android 에서 예전 버전으로 되돌리느라 앱을 지웠다가 설치한 경우: 공용 폴더의 보관본
|
||||
from = snap.sharedFor(current);
|
||||
if (from != null) {
|
||||
message = trf('앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n'
|
||||
'(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)', [p.basename(from)]);
|
||||
}
|
||||
} else if (snap.shouldOffer(current, c.settings.lastRunVersion)) {
|
||||
from = snap.dirOf(current);
|
||||
message = trf('다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\n'
|
||||
'v{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?',
|
||||
[c.settings.lastRunVersion.isEmpty ? tr('예전 버전') : 'v${c.settings.lastRunVersion}', current]);
|
||||
}
|
||||
if (from != null && message != null && context.mounted) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('설정 되살리기')),
|
||||
content: SizedBox(width: 520, child: Text(message!)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지금 설정 그대로'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('되살리고 다시 시작'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
try {
|
||||
await snap.restoreFrom(from);
|
||||
c.note(trf('보관해 둔 설정을 되살렸습니다: {0}', [from]));
|
||||
await restart();
|
||||
return;
|
||||
} catch (e) {
|
||||
c.note(trf('설정을 되살리지 못했습니다: {0}', [e]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.settings.lastRunVersion != current) await c.updateSettings((x) => x.lastRunVersion = current);
|
||||
}
|
||||
|
|
@ -351,6 +351,22 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
|
||||
expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
|
||||
|
||||
// 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에)
|
||||
await act(tester, () => tester.tap(find.text('rsub')));
|
||||
await settle(tester, () => false, rounds: 5); // rsub 를 다시 눌러 취소
|
||||
await act(tester, () => tester.tap(find.text('rsub')));
|
||||
await settle(tester, () => false, rounds: 5); // 다시 골라 지금 폴더로
|
||||
await act(tester, () => tester.tap(find.text('새 폴더')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), '새로만든');
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
|
||||
await settle(tester, () => find.text('새로만든').evaluate().isNotEmpty);
|
||||
expect(Directory(p.join(right, 'rsub', '새로만든')).existsSync(), isTrue);
|
||||
final newRow = find.ancestor(of: find.text('새로만든'), matching: find.byType(Material)).first;
|
||||
expect(find.descendant(of: newRow, matching: find.byTooltip('고르기 취소')), findsOneWidget);
|
||||
expect(find.descendant(of: find.ancestor(of: find.text('rsub'), matching: find.byType(Material)).first,
|
||||
matching: find.byTooltip('고르기 취소')), findsNothing); // 창마다 하나만
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,19 @@ void main() {
|
|||
['src/sub/b.txt']);
|
||||
});
|
||||
|
||||
test('rsync 진행 줄의 전송 속도 (바이트/초)', () {
|
||||
final o = RsyncOutput();
|
||||
o.feed(' 52,428,800 45% 12.50MB/s 0:00:05\r');
|
||||
expect(o.currentPercent, 0.45);
|
||||
expect(o.currentSpeed, 12.5 * 1024 * 1024);
|
||||
o.feed(' 512,000 100% 500.00kB/s 0:00:01 (xfr#2, to-chk=0/3)\n');
|
||||
expect(o.currentSpeed, 500 * 1024);
|
||||
// 폴더 전체: to-chk=남은/전체 → 확인한 수
|
||||
expect([o.checked, o.checkTotal], [3, 3]);
|
||||
o.feed(' 100 100% 1.00kB/s 0:00:00 (xfr#3, ir-chk=1000/1250)\n');
|
||||
expect([o.checked, o.checkTotal], [250, 1250]);
|
||||
});
|
||||
|
||||
test('robocopy 출력 읽기: New File 줄 · % 줄', () {
|
||||
final o = RobocopyOutput();
|
||||
expect(o.feed('\n\t New File \t\t 3\tC:\\t\\src\\a.txt\r\n45% \r\n'), [r'C:\t\src\a.txt']);
|
||||
|
|
@ -110,6 +123,22 @@ void main() {
|
|||
expect(File(p.join(dst, 'single.txt')).readAsStringSync(), 's');
|
||||
}
|
||||
|
||||
test('현재 방식 복사: 전송 속도를 계산하고 끝나면 비움', () async {
|
||||
final src = Directory(p.join(tmp.path, 'speed_src'))..createSync();
|
||||
for (var i = 0; i < 4; i++) {
|
||||
File(p.join(src.path, 'f$i.bin')).writeAsBytesSync(List.filled(256 * 1024, i));
|
||||
}
|
||||
final job = TransferJob(sources: [src.path], dest: Directory(p.join(tmp.path, 'speed_dst')).path, move: false, bandwidthKBps: 2048);
|
||||
Directory(job.dest).createSync();
|
||||
double? seen;
|
||||
job.addListener(() => seen = job.speed ?? seen);
|
||||
await job.run();
|
||||
expect(job.error, isNull);
|
||||
expect(seen, isNotNull);
|
||||
expect(seen!, greaterThan(0));
|
||||
expect(job.speed, isNull); // 끝나면 표시하지 않음
|
||||
});
|
||||
|
||||
test('현재 방식: 진행 (항목 2개 · 폴더 안 파일 3개) · 이동', () async {
|
||||
final job = TransferJob(sources: [src, p.join(tmp.path, 'single.txt')], dest: dst, move: false);
|
||||
final seen = <(int, int, int)>[];
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/version_snapshot.dart';
|
||||
import 'package:jj_mkvmaker/core/app_update.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/services/updater.dart';
|
||||
import 'package:jj_mkvmaker/ui/update_dialog.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
Map<String, dynamic> _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => {
|
||||
'tag_name': tag,
|
||||
|
|
@ -32,8 +37,16 @@ class _FakeUpdater implements Updater {
|
|||
/// 설치할 때 "설치 허용 필요" 로 실패할 횟수 (Android)
|
||||
int needPermission = 0;
|
||||
ReleaseInfo? next;
|
||||
List<ReleaseInfo> all = [];
|
||||
|
||||
/// 받은 파일 (없으면 C:\tmp\new)
|
||||
String? downloaded;
|
||||
final calls = <String>[];
|
||||
@override
|
||||
Future<List<ReleaseInfo>> releases() async => all;
|
||||
@override
|
||||
Future<void> uninstallSelf() async => calls.add('uninstall');
|
||||
@override
|
||||
Future<String> currentVersion() async => '1.0.1';
|
||||
@override
|
||||
Future<ReleaseInfo?> latest() async => next;
|
||||
|
|
@ -43,7 +56,7 @@ class _FakeUpdater implements Updater {
|
|||
Future<String> download(ReleaseInfo r, void Function(double) onProgress) async {
|
||||
calls.add('download ${r.version}');
|
||||
onProgress(1);
|
||||
return r'C:\tmp\new';
|
||||
return downloaded ?? r'C:\tmp\new';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -133,5 +146,94 @@ void main() {
|
|||
await open(tester, manual: false);
|
||||
expect(find.text('새 버전 v1.1.0'), findsNothing);
|
||||
});
|
||||
|
||||
group('버전 고르기 · 되돌리기', () {
|
||||
late Directory data;
|
||||
setUp(() {
|
||||
data = Directory.systemTemp.createTempSync('jj_ver_');
|
||||
File(p.join(data.path, 'settings.json'))
|
||||
.writeAsStringSync(jsonEncode({'uiLanguage': 'ko', 'openSubtitlesKey': 'SECRET-KEY', 'lastRunVersion': '1.0.1'}));
|
||||
File(p.join(data.path, 'videos.json')).writeAsStringSync('[]');
|
||||
VersionSnapshot.instance = VersionSnapshot(data.path, sharedDir: p.join(data.path, 'shared', '설정 보관'));
|
||||
up.all = [
|
||||
parseRelease(_release('v1.0.2'), allowPrerelease: true)!,
|
||||
parseRelease(_release('v1.0.1'), allowPrerelease: true)!,
|
||||
parseRelease(_release('v1.0.0'), allowPrerelease: true)!,
|
||||
];
|
||||
});
|
||||
tearDown(() {
|
||||
VersionSnapshot.instance = null;
|
||||
data.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> openPicker(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(builder: (ctx) => TextButton(onPressed: () => chooseVersion(ctx, c), child: const Text('고르기'))),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('고르기'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('올려 둔 모든 버전 · 지금 · 최신 표시 → 예전 버전으로 되돌리기 (설치 전 설정 보관, 비밀 값 빼고)', (tester) async {
|
||||
await openPicker(tester);
|
||||
expect(find.text('버전 고르기'), findsOneWidget);
|
||||
for (final v in ['v1.0.2', 'v1.0.1', 'v1.0.0']) {
|
||||
expect(find.text(v), findsWidgets);
|
||||
}
|
||||
expect(find.text('지금'), findsOneWidget);
|
||||
expect(find.text('최신'), findsOneWidget);
|
||||
expect(find.text('다시 설치'), findsOneWidget); // 처음엔 지금 버전이 골라져 있음
|
||||
await tester.tap(find.text('v1.0.2').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('이 버전으로 업데이트'), findsOneWidget);
|
||||
await tester.tap(find.text('v1.0.0').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('이 버전으로 되돌리기'), findsOneWidget);
|
||||
expect(find.textContaining('예전 버전으로 되돌립니다'), findsOneWidget);
|
||||
await tester.tap(find.text('이 버전으로 되돌리기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('예전 버전 (v1.0.0) 으로 되돌린 뒤'), findsOneWidget);
|
||||
await tester.tap(find.text('지금 설치'));
|
||||
for (var i = 0; i < 50 && up.calls.length < 2; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(up.calls, ['download 1.0.0', r'install C:\tmp\new']);
|
||||
// 지금 버전 (1.0.1) 의 설정을 보관 (비밀 값은 빼고)
|
||||
final kept = File(p.join(data.path, 'version_snapshots', '1.0.1', 'settings.json'));
|
||||
expect(kept.existsSync(), isTrue);
|
||||
expect(kept.readAsStringSync(), isNot(contains('SECRET-KEY')));
|
||||
expect(File(p.join(data.path, 'version_snapshots', '1.0.1', 'videos.json')).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Android: 예전 버전은 APK 와 설정을 Download 에 저장하고 앱 지우기', (tester) async {
|
||||
up.installsInPlace = true;
|
||||
final apk = File(p.join(data.path, 'dl.apk'))..writeAsStringSync('apk');
|
||||
up.downloaded = apk.path;
|
||||
await openPicker(tester);
|
||||
await tester.tap(find.text('v1.0.0').first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('이 버전으로 되돌리기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Android 는 버전이 낮은 앱을'), findsOneWidget);
|
||||
await tester.tap(find.text('저장하고 계속'));
|
||||
// 받기 · 파일 복사 (실제 파일 작업) 가 끝날 때까지
|
||||
for (var i = 0; i < 50 && find.text('저장했습니다').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('저장했습니다'), findsOneWidget);
|
||||
expect(File(p.join(data.path, 'shared', 'JJ_MKVMaker_v1.0.0_win64.zip')).existsSync(), isTrue); // 설치 파일 사본
|
||||
expect(File(p.join(data.path, 'shared', '설정 보관', '1.0.1', 'settings.json')).existsSync(), isTrue);
|
||||
await tester.tap(find.text('앱 지우기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(up.calls, ['download 1.0.0', 'uninstall']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/version_snapshot.dart';
|
||||
import 'package:jj_mkvmaker/core/app_update.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
void main() {
|
||||
late Directory data;
|
||||
setUp(() => data = Directory.systemTemp.createTempSync('jj_snap_'));
|
||||
tearDown(() => data.deleteSync(recursive: true));
|
||||
|
||||
void writeSettings(Map<String, dynamic> j) => File(p.join(data.path, 'settings.json')).writeAsStringSync(jsonEncode(j));
|
||||
Map<String, dynamic> readSettings() =>
|
||||
jsonDecode(File(p.join(data.path, 'settings.json')).readAsStringSync()) as Map<String, dynamic>;
|
||||
|
||||
test('보관 (비밀 값 빼고) → 다른 버전이 설정을 쓴 뒤 → 되살리기 (지금 비밀 값은 그대로)', () async {
|
||||
final snap = VersionSnapshot(data.path);
|
||||
writeSettings({'uiLanguage': 'ja', 'newOption': 7, 'lastRunVersion': '2.0.0', 'openSubtitlesKey': 'OLD-KEY'});
|
||||
File(p.join(data.path, 'videos.json')).writeAsStringSync('["a.mkv"]');
|
||||
expect(snap.has('2.0.0'), isFalse);
|
||||
await snap.save('2.0.0');
|
||||
expect(snap.has('2.0.0'), isTrue);
|
||||
expect(File(p.join(snap.dirOf('2.0.0'), 'settings.json')).readAsStringSync(), isNot(contains('OLD-KEY')));
|
||||
// 예전 버전이 실행되며 설정을 저장: 모르는 항목 (newOption · lastRunVersion) 이 사라짐, 키는 새로 넣음
|
||||
writeSettings({'uiLanguage': 'en', 'openSubtitlesKey': 'NEW-KEY'});
|
||||
File(p.join(data.path, 'videos.json')).writeAsStringSync('[]');
|
||||
expect(snap.shouldOffer('2.0.0', ''), isTrue); // 2.0.0 으로 돌아옴 · 마지막 실행 버전이 비어 있음
|
||||
expect(snap.shouldOffer('2.0.0', '2.0.0'), isFalse);
|
||||
expect(snap.shouldOffer('1.0.0', ''), isFalse); // 보관본 없는 버전
|
||||
await snap.restoreFrom(snap.dirOf('2.0.0'));
|
||||
final j = readSettings();
|
||||
expect([j['uiLanguage'], j['newOption'], j['lastRunVersion'], j['openSubtitlesKey']], ['ja', 7, '2.0.0', 'NEW-KEY']);
|
||||
expect(File(p.join(data.path, 'videos.json')).readAsStringSync(), '["a.mkv"]');
|
||||
});
|
||||
|
||||
test('공용 폴더 (Android, 앱을 다시 설치해도 남음): 같은 버전, 없으면 가장 최근', () async {
|
||||
final shared = p.join(data.path, 'shared');
|
||||
final snap = VersionSnapshot(data.path, sharedDir: shared);
|
||||
writeSettings({'a': 1});
|
||||
await snap.save('2026.10.05_003');
|
||||
await snap.save('2026.10.07_001');
|
||||
expect(File(p.join(shared, '2026.10.05_003', 'settings.json')).existsSync(), isTrue);
|
||||
expect(p.basename(snap.sharedFor('2026.10.05_003')!), '2026.10.05_003');
|
||||
expect(p.basename(snap.sharedFor('2026.10.01_001')!), '2026.10.07_001');
|
||||
expect(VersionSnapshot(data.path).sharedFor('x'), isNull);
|
||||
});
|
||||
|
||||
test('릴리스 목록: 초안 빼고 · 시험판 표시 · 새 버전이 앞 · 올린 때', () {
|
||||
Map<String, dynamic> rel(String tag, {bool draft = false, bool pre = false}) => {
|
||||
'tag_name': tag,
|
||||
'draft': draft,
|
||||
'prerelease': pre,
|
||||
'published_at': '2026-10-04T18:22:00Z',
|
||||
'assets': [
|
||||
{'name': 'JJ_MKVMaker_${tag}_win64.zip', 'browser_download_url': 'https://x/$tag.zip', 'size': 1},
|
||||
],
|
||||
};
|
||||
final list = parseReleaseList([
|
||||
rel('v2026.10.04_012'),
|
||||
rel('v2026.10.05_003'),
|
||||
rel('v2026.10.06_001', draft: true),
|
||||
rel('v2026.10.05_004', pre: true),
|
||||
]);
|
||||
expect(list.map((r) => r.version), ['2026.10.05_004', '2026.10.05_003', '2026.10.04_012']);
|
||||
expect(list.first.prerelease, isTrue);
|
||||
expect(list.first.published, DateTime.utc(2026, 10, 4, 18, 22));
|
||||
expect(parseLatestRelease(rel('v2026.10.05_004', pre: true)), isNull); // 자동 확인은 시험판 제외 그대로
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue