복사 모니터링 · 복사 기억 · lsync 일정 (cron + 격자 편집)

- 설정 [복사 모니터링] 켜면 파일 탐색기 가운데에 [모니터링] 버튼
- 복사 · rsync 탭: 복사 대상 기억, 진행률, 대상 디스크 용량, 옵션 고치기 → 저장 후 현재 유지 / 설정 반영 후 실행, 실행 · 재개 · 취소 · 지우기, lsync 로 이동
- lsync 탭: 맞출 파일 자동 표시, 지금 맞추기, 복사 · rsync 로 이동
- 동기화 시간: 계속 / cron 시간 창 (요일 × 시 격자로 칠해서 편집)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 11:16:05 +09:00
parent 7308f8ce1b
commit b38253fd67
16 changed files with 1938 additions and 40 deletions

View File

@ -64,6 +64,14 @@ class MainActivity : FlutterActivity() {
result.error("INSTALL", e.message ?: e.toString(), null)
}
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
"diskSpace" -> result.success(
try {
val st = android.os.StatFs(call.argument<String>("path") ?: "")
listOf(st.availableBytes, st.totalBytes)
} catch (e: Exception) {
null
}
)
"openWith" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
)

View File

@ -14,6 +14,7 @@
" / 전체 {0}": " / total {0}",
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0} are larger than the original: the file grows without better quality",
" · 다시 시작해야 Chrome 으로 바뀝니다": " · Restart to switch to Chrome",
" · 다음 시작 시간에 맞춥니다": " · synced at the next start time",
" · 대기 {0}": " · {0} waiting",
" · 대기 {0}개": " · {0} waiting",
" · 전에 만든 자막 {0}개 (jj_mkv)": " · {0} subtitles made earlier (jj_mkv)",
@ -32,6 +33,7 @@
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(Languages cannot be added because AI translation is unavailable on this device)",
"(비어 있음)": "(empty)",
"(설명 없음)": "(no description)",
"(현재 방식은 옵션 없음)": "(no options for the built-in method)",
"+ 여기에 새 줄": "+ New line here",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
@ -144,8 +146,11 @@
"aria2 오류: {0}": "aria2 error: {0}",
"cookies.txt (Netscape 형식)": "cookies.txt (Netscape format)",
"cookies.txt 파일…": "cookies.txt file…",
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (minute hour day month weekday) · one per line",
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
"lsync (실시간 동기화)": "lsync (live sync)",
"lsync 로 이동": "Move to lsync",
"quit 시작": "quit started",
"robocopy 옵션": "robocopy options",
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE. When off, only adds · updates in the target",
@ -180,6 +185,7 @@
"{0} 안의 파일": "Files in {0}",
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
"{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} 추가": "Add {0}",
"{0} 파일": "{0} file",
@ -193,6 +199,7 @@
"{0}개 · 주소 {1}개": "{0} · {1} links",
"{0}개 맞춤": "{0} synced",
"{0}개 선택": "{0} selected",
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "Moved {0} folders to lsync (live sync).",
"{0}개 표시함": "{0} marked",
"{0}개 항목을 {1}": "{0} items {1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
@ -238,6 +245,8 @@
"결과 폴더 열기 (동영상을 고르세요)": "Open output folder (choose a video)",
"결과 폴더 열기: {0}": "Open output folder: {0}",
"결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})",
"계속": "Always",
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
"계속 편집": "Keep editing",
"고르기": "Choose",
"고르지 않음": "Not chosen",
@ -249,7 +258,9 @@
"권한 허용 화면 열기": "Open the permission screen",
"그 파일 하나만": "Only that file",
"그리스어": "Greek",
"글을 격자에 반영": "Apply text to grid",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)",
"금": "Fri",
"기계 번역": "Machine translation",
"기기": "device",
"기능 버튼 줄": "Button bar",
@ -263,6 +274,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
"기본값": "Default",
"기본값으로 ({0})": "Default ({0})",
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.",
"기타 즐겨찾기": "Other bookmarks",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
"길이 {0}": "Length {0}",
@ -274,6 +286,7 @@
"끝": "End",
"끝 (오른쪽 · 아래)": "Edge (right · bottom)",
"끝 = 현재": "End = now",
"끝 · {0} · 파일 {1}개": "Done · {0} · {1} files",
"끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}",
"끝: {0}": "End: {0}",
"끝나지 못했습니다: {0}": "Did not finish: {0}",
@ -312,6 +325,7 @@
"노르웨이어": "Norwegian",
"누르고 있으면 조정 전": "Hold to see before",
"누르기": "Tap",
"누르면 일정 바꾸기": "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)",
"다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser",
@ -327,6 +341,7 @@
"다른 창으로 이동": "Move to other pane",
"다른 폴더로 이동": "Move to another folder",
"다시 만들기 대기로": "Back to waiting for remake",
"다시 세기": "Recount",
"다시 시작": "Restart",
"다운로드": "Downloads",
"다운로드 (jj_yt-dlp · jj_aria2)": "Downloads (jj_yt-dlp · jj_aria2)",
@ -358,9 +373,12 @@
"닫기": "Close",
"대기": "Waiting",
"대기 {0}개:": "{0} waiting:",
"대기 · 다음 시작 {0}": "Waiting · next start {0}",
"대기 중인 작업 {0}개 취소": "Cancel {0} waiting jobs",
"대기열에 추가: {0} (대기 {1}개)": "Added to queue: {0} ({1} waiting)",
"대비": "Contrast",
"대상 디스크: 남은 {0} / 전체 {1}": "Target disk: {0} free / {1} total",
"대상 디스크: 알 수 없음": "Target disk: unknown",
"대상 폴더 열기": "Open containing folder",
"더 보기": "More",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)",
@ -368,6 +386,8 @@
"덴마크어": "Danish",
"독일어": "German",
"돌아가기": "Go back",
"동기화 시간": "Sync time",
"동기화 시간 (계속 / 시간 지정)": "Sync time (always / scheduled)",
"동시 MKV 변환 수": "Simultaneous MKV conversions",
"동시 다운로드 수": "Simultaneous downloads",
"동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)",
@ -386,6 +406,7 @@
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "To choose videos and make MKVs in the jj_mkv folder next to them,\n\"All files access\" permission is needed.",
"동영상을 끌어다 놓으면 목록에 추가됩니다": "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",
"두 창": "Two panes",
"두 창 배치": "Two-pane arrangement",
@ -398,6 +419,7 @@
"러시아어": "Russian",
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
"루마니아어": "Romanian",
"마지막": "Last",
"마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings",
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
"말레이어": "Malay",
@ -405,9 +427,15 @@
"맞추기 (비율이 다를 때)": "Conform (when the aspect differs)",
"맞추기 (흐린 배경)": "Fit (blurred background)",
"맞추는 중…": "Syncing…",
"맞출 것 {0}개{1}": "{0} to sync{1}",
"맞출 것 없음 (원본과 대상이 같습니다)": "Nothing to sync (source and target match)",
"맞출 것을 세는 중…": "Counting what to sync…",
"맞춤": "Synced",
"매번 고르기": "Ask every time",
"매일 종일": "Every day, all day",
"멈춤": "Stopped",
"멈춤 · {0}": "Stopped · {0}",
"모니터링": "Monitor",
"모델 내려받는 중 {0}%": "Downloading model {0}%",
"모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})",
@ -418,9 +446,11 @@
"모두 지우기": "Clear all",
"모두 초기화": "Reset all",
"모든 작업 취소": "Cancel all jobs",
"목": "Thu",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list",
"목록에서 제거": "Remove from list",
"목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)",
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
"몽골어": "Mongolian",
"무한": "Unlimited",
@ -428,6 +458,7 @@
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
"미얀마어": "Burmese",
"미지정": "Unspecified",
"바꾸기": "Change",
"바꾸면 바로 저장됩니다": "Changes are saved right away",
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바뀐 날": "Modified",
@ -448,6 +479,7 @@
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
"받음 {0}": "Received {0}",
"밝기": "Brightness",
"밤 0~6시": "Night 0-6",
"방법: {0}": "Method: {0}",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)",
"백그라운드로": "To background",
@ -470,8 +502,14 @@
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
"복사": "Copy",
"복사 (붙여넣기로)": "Copy (to paste)",
"복사 · rsync": "Copy · rsync",
"복사 · rsync 로 이동": "Move to copy · rsync",
"복사 · rsync 목록으로 옮겼습니다.": "Moved to the copy · rsync list.",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "Copy · move works with two panes (Pane layout > Two panes).",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy · move (tap, or mark with the circle on the right).",
"복사 끝 ({0}): {1}개 → {2}": "Copy done ({0}): {1} → {2}",
"복사 모니터링": "Copy monitor",
"복사 실패 ({0}): {1}": "Copy failed ({0}): {1}",
"복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs",
"복사하는 중": "Copying",
"복사할까요?": "copy?",
@ -523,6 +561,7 @@
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "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}",
"설정 반영 후 실행": "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).",
"설치 계속": "Continue install",
"설치 실패": "Install failed",
@ -537,8 +576,10 @@
"세로 4:5": "Portrait 4:5",
"세로 9:16": "Portrait 9:16",
"세로 고정": "Portrait lock",
"속도 제한": "Speed limit",
"속도 제한 (KB/s)": "Speed limit (KB/s)",
"속도 제한 {0} KB/s": "Speed limit {0} KB/s",
"수": "Wed",
"수정": "Edit",
"수정 · 이동": "Edit · move",
"수정한 날짜": "Date modified",
@ -551,6 +592,7 @@
"스웨덴어": "Swedish",
"스타일": "Style",
"스페인어": "Spanish",
"시간 지정 (cron)": "Scheduled (cron)",
"시간 형식 오류 {0}곳": "{0} time format errors",
"시간순 정렬": "Sort by time",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02",
@ -567,7 +609,9 @@
"실시간 동기화 추가": "Add live sync",
"실시간 동기화: 대상 폴더": "Live sync: target folder",
"실시간 동기화: 원본 폴더": "Live sync: source folder",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "No live sync. [Add] one, or use [Move to lsync] in the copy · rsync tab.",
"실패": "Failed",
"실패 · {0}": "Failed · {0}",
"실패: {0}": "Failed: {0}",
"실패: {0}\n{1}": "Failed: {0}\n{1}",
"실행": "Run",
@ -583,6 +627,7 @@
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).",
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])",
"아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}",
"아직 실행 안 함": "Not run yet",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).",
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])",
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
@ -595,6 +640,7 @@
"앱 안 브라우저 (권장)": "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)",
"앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
"언어": "Language",
"언어 추가": "Add language",
@ -620,6 +666,7 @@
"영어": "English",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "e.g. -avPog (archive · verbose · progress · owner · group). Move adds --remove-source-files automatically",
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "e.g. /E /COPY:DAT /DCOPY:T /R:2 /W:2. Move adds /MOVE automatically",
"예: 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}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})",
@ -630,6 +677,8 @@
"옮겼습니다.": "moved.",
"옮기는 중": "Moving",
"옮길까요?": "move?",
"옵션": "Options",
"옵션을 저장했습니다": "Options saved",
"완료": "Done",
"완료 정리": "Clear completed",
"완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total",
@ -654,10 +703,12 @@
"원본과 대상이 서로 안에 있습니다": "Source and target are inside each other",
"원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source",
"원본의 {0}배": "{0}× original",
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "When the source changes, the target is synced. With a schedule, sync happens only in that time; changes outside it are collected and synced at the next start time.",
"원어 (영상 속 언어)": "Source language (spoken in the video)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
"원어: {0} ({1})": "Source: {0} ({1})",
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
"월": "Mon",
"웹 브라우저": "Web browser",
"웹 브라우저 (지금 여기)": "Web browser (you are here)",
"웹 브라우저 (홈 주소)": "Web browser (home page)",
@ -687,6 +738,7 @@
"이 버전 건너뛰기": "Skip this version",
"이 버전에는 Android 용 APK 가 없습니다.": "This version has no APK for Android.",
"이 버전에는 Windows 용 zip 이 없습니다.": "This version has no zip for Windows.",
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "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.",
"이 언어 지우기": "Remove this language",
"이 이동 버튼 지우기": "Delete this move button",
@ -738,6 +790,7 @@
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
"인터넷 자막 찾기": "Find subtitles online",
"인터넷 자막 찾기{0}": "Find subtitles online{0}",
"일": "Sun",
"일반": "General",
"일본어": "Japanese",
"일시정지": "Pause",
@ -846,6 +899,7 @@
"종료할까요?": "Quit?",
"종류 (확장자)": "Type (extension)",
"좌우": "Side by side",
"주말 종일": "Weekends, all day",
"주소": "Address",
"주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)",
"준비 중": "Preparing",
@ -870,6 +924,7 @@
"즐겨찾기 추가…": "Add bookmark…",
"즐겨찾기 추가됨": "Bookmark added",
"즐겨찾기 표시줄": "Bookmarks bar",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
"지금 맞추기": "Sync now",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
"지금 설치": "Install now",
@ -925,6 +980,8 @@
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)",
"취소됨": "Cancelled",
"취소했습니다.": "Cancelled.",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).",
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
"켜져 있는 창에서 (기본)": "In the open window (default)",
"코덱": "Codec",
"크기": "Size",
@ -936,6 +993,7 @@
"탐색기에서 연 동영상을 재생할 창": "Window to play videos opened from Explorer",
"태국어": "Thai",
"터키어": "Turkish",
"토": "Sat",
"토렌트 · 마그넷 다운로드": "Torrent · magnet download",
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
"트랙 {0}": "Track {0}",
@ -951,6 +1009,7 @@
"파일 이름": "File name",
"파일 탐색기": "File explorer",
"파일 탐색기 (지금 여기)": "File explorer (you are here)",
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "[Monitor] button in the middle of the file explorer: remembers copies · moves and shows progress · target disk space · options; change options to run again · resume, or move to lsync (copy · rsync and lsync shown separately).",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)",
"파일 폴더": "File folder",
"파일 확인 중…": "Checking files…",
@ -965,6 +1024,7 @@
"편집 목록에 있음": "In the edit list",
"편집 목록에 추가": "Add to the edit list",
"편집 목록에 추가 (MKV 만들기 대상)": "Add to the edit list (Make MKV target)",
"평일 9~18시": "Weekdays 9-18",
"포르투갈어": "Portuguese",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)",
"폴더": "Folder",
@ -982,6 +1042,7 @@
"폴더가 들어 있을 때": "When folders are included",
"폴더가 아닙니다: {0}": "Not a folder: {0}",
"폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
"폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.",
"폴란드어": "Polish",
"표시 (여러 개 고르기)": "Mark (select several)",
"표시 이름": "Display name",
@ -1018,6 +1079,7 @@
"허용했으면 다시 확인": "Check again after allowing",
"헝가리어": "Hungarian",
"현재 방식": "Built-in",
"현재 유지": "Keep as is",
"현재 페이지": "Current page",
"현재 페이지 추가": "Add current page",
"형식 오류": "Format error",
@ -1025,6 +1087,7 @@
"홈 주소": "Home page",
"홈 화면": "Home screen",
"홈 화면 ({0}) · 환경 설정에서 바꿈": "Home screen ({0}) · change in Settings",
"화": "Tue",
"화면": "Display",
"화면 · 색 보정": "Frame · color",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.",

View File

@ -14,6 +14,7 @@
" / 전체 {0}": " / 全体 {0}",
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0}個は元より大きく、画質は上がらず容量だけ増えます",
" · 다시 시작해야 Chrome 으로 바뀝니다": " · 再起動すると Chrome に切り替わります",
" · 다음 시작 시간에 맞춥니다": " · 次の開始時刻に同期します",
" · 대기 {0}": " · 待機 {0}",
" · 대기 {0}개": " · 待機 {0}個",
" · 전에 만든 자막 {0}개 (jj_mkv)": " · 以前作成した字幕 {0}個(jj_mkv)",
@ -32,6 +33,7 @@
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(この端末では AI 翻訳が使えないため言語を追加できません)",
"(비어 있음)": "(空)",
"(설명 없음)": "(説明なし)",
"(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)",
"+ 여기에 새 줄": "+ ここに新しい行",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
@ -144,8 +146,11 @@
"aria2 오류: {0}": "aria2 エラー: {0}",
"cookies.txt (Netscape 형식)": "cookies.txt(Netscape 形式)",
"cookies.txt 파일…": "cookies.txt ファイル…",
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 時 日 月 曜日) · 1 行に 1 つ",
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
"lsync (실시간 동기화)": "lsync (リアルタイム同期)",
"lsync 로 이동": "lsync へ移動",
"quit 시작": "終了開始",
"robocopy 옵션": "robocopy オプション",
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。オフなら対象に追加 · 更新のみ",
@ -180,6 +185,7 @@
"{0} 안의 파일": "{0} 内のファイル",
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
"{0} 외 {1}개": "{0} ほか {1} 件",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 추가": "{0} を追加",
"{0} 파일": "{0} ファイル",
@ -193,6 +199,7 @@
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
"{0}개 맞춤": "{0} 件を同期",
"{0}개 선택": "{0}個選択",
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "{0} 個のフォルダーを lsync (リアルタイム同期) へ移しました。",
"{0}개 표시함": "{0} 件をマーク",
"{0}개 항목을 {1}": "{0} 件の項目を{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
@ -238,6 +245,8 @@
"결과 폴더 열기 (동영상을 고르세요)": "出力フォルダーを開く(動画を選んでください)",
"결과 폴더 열기: {0}": "出力フォルダーを開く: {0}",
"결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})",
"계속": "常時",
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
"계속 편집": "編集を続ける",
"고르기": "選ぶ",
"고르지 않음": "未選択",
@ -249,7 +258,9 @@
"권한 허용 화면 열기": "権限の許可画面を開く",
"그 파일 하나만": "そのファイルだけ",
"그리스어": "ギリシャ語",
"글을 격자에 반영": "テキストをグリッドに反映",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)",
"금": "金",
"기계 번역": "機械翻訳",
"기기": "端末",
"기능 버튼 줄": "機能ボタンの列",
@ -263,6 +274,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
"기본값": "既定値",
"기본값으로 ({0})": "既定値に戻す ({0})",
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。",
"기타 즐겨찾기": "その他のブックマーク",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
"길이 {0}": "長さ {0}",
@ -274,6 +286,7 @@
"끝": "終了",
"끝 (오른쪽 · 아래)": "端 (右 · 下)",
"끝 = 현재": "終了 = 現在",
"끝 · {0} · 파일 {1}개": "完了 · {0} · ファイル {1} 件",
"끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}",
"끝: {0}": "終了: {0}",
"끝나지 못했습니다: {0}": "完了できませんでした: {0}",
@ -312,6 +325,7 @@
"노르웨이어": "ノルウェー語",
"누르고 있으면 조정 전": "押している間は調整前",
"누르기": "タップ",
"누르면 일정 바꾸기": "タップで予定を変更",
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "ダウンロードが終わり次第 MKV 作成の動画リストに入れ、ダウンロードリストからは外します(ダウンロード画面のチェックボックスと同じ)",
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "ダウンロードが終わると MKV 作成の動画リストに自動で入れ、このダウンロードリストからは外します(ダウンロードしたファイルはそのまま)",
"다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む",
@ -327,6 +341,7 @@
"다른 창으로 이동": "もう一方のペインへ移動",
"다른 폴더로 이동": "他のフォルダーに移動",
"다시 만들기 대기로": "作り直し待ちに戻す",
"다시 세기": "再カウント",
"다시 시작": "再起動",
"다운로드": "ダウンロード",
"다운로드 (jj_yt-dlp · jj_aria2)": "ダウンロード(jj_yt-dlp・jj_aria2)",
@ -358,9 +373,12 @@
"닫기": "閉じる",
"대기": "待機",
"대기 {0}개:": "待機 {0}個:",
"대기 · 다음 시작 {0}": "待機 · 次の開始 {0}",
"대기 중인 작업 {0}개 취소": "待機中の作業 {0}個をキャンセル",
"대기열에 추가: {0} (대기 {1}개)": "キューに追加: {0}(待機 {1}個)",
"대비": "コントラスト",
"대상 디스크: 남은 {0} / 전체 {1}": "対象ディスク: 空き {0} / 全体 {1}",
"대상 디스크: 알 수 없음": "対象ディスク: 不明",
"대상 폴더 열기": "対象フォルダーを開く",
"더 보기": "その他",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
@ -368,6 +386,8 @@
"덴마크어": "デンマーク語",
"독일어": "ドイツ語",
"돌아가기": "戻る",
"동기화 시간": "同期時間",
"동기화 시간 (계속 / 시간 지정)": "同期時間 (常時 / 時間指定)",
"동시 MKV 변환 수": "同時 MKV 変換数",
"동시 다운로드 수": "同時ダウンロード数",
"동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)",
@ -386,6 +406,7 @@
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "動画を選び、動画の隣の jj_mkv フォルダーに MKV を作成するには\n「すべてのファイルへのアクセス」権限が必要です。",
"동영상을 끌어다 놓으면 목록에 추가됩니다": "動画をドラッグ&ドロップするとリストに追加されます",
"동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)",
"동작 중 · {0} 까지": "動作中 · {0} まで",
"되살리기": "元に戻す",
"두 창": "2 ペイン",
"두 창 배치": "2 ペインの配置",
@ -398,6 +419,7 @@
"러시아어": "ロシア語",
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
"루마니아어": "ルーマニア語",
"마지막": "最後",
"마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します",
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
"말레이어": "マレー語",
@ -405,9 +427,15 @@
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
"맞추기 (흐린 배경)": "フィット(ぼかし背景)",
"맞추는 중…": "同期中…",
"맞출 것 {0}개{1}": "同期対象 {0} 件{1}",
"맞출 것 없음 (원본과 대상이 같습니다)": "同期対象なし (元と対象が同じです)",
"맞출 것을 세는 중…": "同期対象を数えています…",
"맞춤": "同期済み",
"매번 고르기": "毎回選ぶ",
"매일 종일": "毎日 終日",
"멈춤": "停止",
"멈춤 · {0}": "停止 · {0}",
"모니터링": "モニタリング",
"모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%",
"모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})",
@ -418,9 +446,11 @@
"모두 지우기": "すべて削除",
"모두 초기화": "すべてリセット",
"모든 작업 취소": "すべての作業をキャンセル",
"목": "木",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る",
"목록에서 제거": "リストから削除",
"목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)",
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
"몽골어": "モンゴル語",
"무한": "無制限",
@ -428,6 +458,7 @@
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
"미얀마어": "ミャンマー語",
"미지정": "未指定",
"바꾸기": "変更",
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
"바뀐 날": "更新日時",
@ -448,6 +479,7 @@
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
"받음 {0}": "受信 {0}",
"밝기": "明るさ",
"밤 0~6시": "夜 0~6 時",
"방법: {0}": "方法: {0}",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)",
"백그라운드로": "バックグラウンドへ",
@ -470,8 +502,14 @@
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
"복사": "コピー",
"복사 (붙여넣기로)": "コピー (貼り付け用)",
"복사 · rsync": "コピー · rsync",
"복사 · rsync 로 이동": "コピー · rsync へ移動",
"복사 · rsync 목록으로 옮겼습니다.": "コピー · rsync の一覧へ移しました。",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "コピー · 移動は 2 ペインで使います (ペインの配置 > 2 ペイン)。",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピー · 移動する項目を選んでください (タップ、または右の丸でマーク)。",
"복사 끝 ({0}): {1}개 → {2}": "コピー完了 ({0}): {1} 件 → {2}",
"복사 모니터링": "コピーのモニタリング",
"복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}",
"복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード",
"복사하는 중": "コピー中",
"복사할까요?": "コピーしますか?",
@ -523,6 +561,7 @@
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "選択したファイルを再生: 今見ている動画(複数はリストでチェック)",
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "選択したファイルを再生: チェックした動画 {0}個をリスト順に続けて",
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
"설정 반영 후 실행": "反映して実行",
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。",
"설치 계속": "インストールを続ける",
"설치 실패": "インストール失敗",
@ -537,8 +576,10 @@
"세로 4:5": "縦 4:5",
"세로 9:16": "縦 9:16",
"세로 고정": "縦向き固定",
"속도 제한": "速度制限",
"속도 제한 (KB/s)": "速度制限 (KB/s)",
"속도 제한 {0} KB/s": "速度制限 {0} KB/s",
"수": "水",
"수정": "編集",
"수정 · 이동": "編集・移動",
"수정한 날짜": "更新日時",
@ -551,6 +592,7 @@
"스웨덴어": "スウェーデン語",
"스타일": "スタイル",
"스페인어": "スペイン語",
"시간 지정 (cron)": "時間指定 (cron)",
"시간 형식 오류 {0}곳": "時間形式エラー {0}か所",
"시간순 정렬": "時間順に並べ替え",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル",
@ -567,7 +609,9 @@
"실시간 동기화 추가": "リアルタイム同期を追加",
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "リアルタイム同期はありません。[追加] するか、コピー · rsync タブで [lsync へ移動] してください。",
"실패": "失敗",
"실패 · {0}": "失敗 · {0}",
"실패: {0}": "失敗: {0}",
"실패: {0}\n{1}": "失敗: {0}\n{1}",
"실행": "実行",
@ -583,6 +627,7 @@
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
"아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}",
"아직 실행 안 함": "まだ実行していません",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。",
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
@ -595,6 +640,7 @@
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
"언어": "言語",
"언어 추가": "言語を追加",
@ -620,6 +666,7 @@
"영어": "英語",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (アーカイブ · 詳細 · 進捗 · 所有者 · グループ)。移動では --remove-source-files を自動で付加",
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加",
"예: 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}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})",
@ -630,6 +677,8 @@
"옮겼습니다.": "移動しました。",
"옮기는 중": "移動中",
"옮길까요?": "移動しますか?",
"옵션": "オプション",
"옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了",
"완료 정리": "完了を整理",
"완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}",
@ -654,10 +703,12 @@
"원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります",
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
"원본의 {0}배": "元の {0}倍",
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。",
"원어 (영상 속 언어)": "原語(動画内の言語)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
"원어: {0} ({1})": "原語: {0}({1})",
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
"월": "月",
"웹 브라우저": "ウェブブラウザー",
"웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)",
"웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)",
@ -687,6 +738,7 @@
"이 버전 건너뛰기": "このバージョンをスキップ",
"이 버전에는 Android 용 APK 가 없습니다.": "このバージョンには Android 用 APK がありません。",
"이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。",
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
"이 언어 지우기": "この言語を削除",
"이 이동 버튼 지우기": "この移動ボタンを削除",
@ -738,6 +790,7 @@
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
"인터넷 자막 찾기": "インターネットで字幕を探す",
"인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}",
"일": "日",
"일반": "一般",
"일본어": "日本語",
"일시정지": "一時停止",
@ -846,6 +899,7 @@
"종료할까요?": "終了しますか?",
"종류 (확장자)": "種類 (拡張子)",
"좌우": "左右",
"주말 종일": "週末 終日",
"주소": "アドレス",
"주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)",
"준비 중": "準備中",
@ -870,6 +924,7 @@
"즐겨찾기 추가…": "ブックマークを追加…",
"즐겨찾기 추가됨": "ブックマークを追加しました",
"즐겨찾기 표시줄": "ブックマークバー",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
"지금 맞추기": "今すぐ同期",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
"지금 설치": "今すぐインストール",
@ -925,6 +980,8 @@
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)",
"취소됨": "キャンセル済み",
"취소했습니다.": "キャンセルしました。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
"코덱": "コーデック",
"크기": "サイズ",
@ -936,6 +993,7 @@
"탐색기에서 연 동영상을 재생할 창": "エクスプローラーで開いた動画を再生するウィンドウ",
"태국어": "タイ語",
"터키어": "トルコ語",
"토": "土",
"토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード",
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
"트랙 {0}": "トラック {0}",
@ -951,6 +1009,7 @@
"파일 이름": "ファイル名",
"파일 탐색기": "ファイルエクスプローラー",
"파일 탐색기 (지금 여기)": "ファイルエクスプローラー (現在地)",
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "ファイルエクスプローラー中央に [モニタリング] ボタン: コピー · 移動を記憶して進捗 · 対象ディスク容量 · オプションを表示し、オプションを変えて再実行 · 再開、または lsync へ移動します (コピー · rsync と lsync は別々)。",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)",
"파일 폴더": "ファイル フォルダー",
"파일 확인 중…": "ファイルを確認中…",
@ -965,6 +1024,7 @@
"편집 목록에 있음": "編集リストにあり",
"편집 목록에 추가": "編集リストに追加",
"편집 목록에 추가 (MKV 만들기 대상)": "編集リストに追加(MKV 作成の対象)",
"평일 9~18시": "平日 9~18 時",
"포르투갈어": "ポルトガル語",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)",
"폴더": "フォルダー",
@ -982,6 +1042,7 @@
"폴더가 들어 있을 때": "フォルダーを含むとき",
"폴더가 아닙니다: {0}": "フォルダーではありません: {0}",
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
"폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。",
"폴란드어": "ポーランド語",
"표시 (여러 개 고르기)": "マーク (複数選択)",
"표시 이름": "表示名",
@ -1018,6 +1079,7 @@
"허용했으면 다시 확인": "許可したらもう一度確認",
"헝가리어": "ハンガリー語",
"현재 방식": "現在の方式",
"현재 유지": "現状維持",
"현재 페이지": "現在のページ",
"현재 페이지 추가": "現在のページを追加",
"형식 오류": "形式エラー",
@ -1025,6 +1087,7 @@
"홈 주소": "ホームページ",
"홈 화면": "ホーム画面",
"홈 화면 ({0}) · 환경 설정에서 바꿈": "ホーム画面({0})・環境設定で変更",
"화": "火",
"화면": "画面",
"화면 · 색 보정": "画面・カラー補正",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。",

View File

@ -14,6 +14,7 @@
" / 전체 {0}": " / 共 {0}",
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0} 个比原始尺寸大,只会增加体积而不会提升画质",
" · 다시 시작해야 Chrome 으로 바뀝니다": " · 重新启动后切换到 Chrome",
" · 다음 시작 시간에 맞춥니다": " · 将在下次开始时间同步",
" · 대기 {0}": " · 等待 {0}",
" · 대기 {0}개": " · 等待 {0} 个",
" · 전에 만든 자막 {0}개 (jj_mkv)": " · 之前制作的字幕 {0} 个(jj_mkv)",
@ -32,6 +33,7 @@
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(此设备无法使用 AI 翻译,因此无法添加语言)",
"(비어 있음)": "(空)",
"(설명 없음)": "(无说明)",
"(현재 방식은 옵션 없음)": "(当前方式没有选项)",
"+ 여기에 새 줄": "+ 在此新增一行",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
@ -144,8 +146,11 @@
"aria2 오류: {0}": "aria2 错误: {0}",
"cookies.txt (Netscape 형식)": "cookies.txt(Netscape 格式)",
"cookies.txt 파일…": "cookies.txt 文件…",
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 时 日 月 星期) · 每行一个",
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
"lsync (실시간 동기화)": "lsync (实时同步)",
"lsync 로 이동": "移到 lsync",
"quit 시작": "开始退出",
"robocopy 옵션": "robocopy 选项",
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。关闭时只在目标中添加 · 更新",
@ -180,6 +185,7 @@
"{0} 안의 파일": "{0} 中的文件",
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
"{0} 외 {1}개": "{0} 等 {1} 个",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 추가": "添加 {0}",
"{0} 파일": "{0} 文件",
@ -193,6 +199,7 @@
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
"{0}개 맞춤": "已同步 {0} 个",
"{0}개 선택": "已选 {0} 个",
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "已将 {0} 个文件夹移到 lsync (实时同步)。",
"{0}개 표시함": "已标记 {0} 个",
"{0}개 항목을 {1}": "{0} 个项目{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
@ -238,6 +245,8 @@
"결과 폴더 열기 (동영상을 고르세요)": "打开输出文件夹(请选择视频)",
"결과 폴더 열기: {0}": "打开输出文件夹: {0}",
"결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})",
"계속": "持续",
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
"계속 편집": "继续编辑",
"고르기": "选择",
"고르지 않음": "未选择",
@ -249,7 +258,9 @@
"권한 허용 화면 열기": "打开权限授予界面",
"그 파일 하나만": "仅该文件",
"그리스어": "希腊语",
"글을 격자에 반영": "将文本应用到网格",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)",
"금": "五",
"기계 번역": "机器翻译",
"기기": "设备",
"기능 버튼 줄": "功能按钮栏",
@ -263,6 +274,7 @@
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
"기본값": "默认值",
"기본값으로 ({0})": "恢复默认 ({0})",
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。",
"기타 즐겨찾기": "其他书签",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
"길이 {0}": "时长 {0}",
@ -274,6 +286,7 @@
"끝": "结束",
"끝 (오른쪽 · 아래)": "边缘 (右 · 下)",
"끝 = 현재": "结束 = 当前",
"끝 · {0} · 파일 {1}개": "完成 · {0} · {1} 个文件",
"끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}",
"끝: {0}": "结束: {0}",
"끝나지 못했습니다: {0}": "未能完成: {0}",
@ -312,6 +325,7 @@
"노르웨이어": "挪威语",
"누르고 있으면 조정 전": "按住查看调整前",
"누르기": "点按",
"누르면 일정 바꾸기": "点按以更改计划",
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "下载完成后立即放入制作 MKV 的视频列表,并从下载列表移除(与下载界面的复选框相同)",
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "下载完成后自动加入制作 MKV 的视频列表,并从此下载列表移除(已下载的文件保留)",
"다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签",
@ -327,6 +341,7 @@
"다른 창으로 이동": "移动到另一窗格",
"다른 폴더로 이동": "移动到其他文件夹",
"다시 만들기 대기로": "改回等待重新制作",
"다시 세기": "重新统计",
"다시 시작": "重新启动",
"다운로드": "下载",
"다운로드 (jj_yt-dlp · jj_aria2)": "下载(jj_yt-dlp · jj_aria2)",
@ -358,9 +373,12 @@
"닫기": "关闭",
"대기": "等待",
"대기 {0}개:": "等待 {0} 个:",
"대기 · 다음 시작 {0}": "等待 · 下次开始 {0}",
"대기 중인 작업 {0}개 취소": "取消 {0} 个等待中的任务",
"대기열에 추가: {0} (대기 {1}개)": "已加入队列: {0}(等待 {1} 个)",
"대비": "对比度",
"대상 디스크: 남은 {0} / 전체 {1}": "目标磁盘: 剩余 {0} / 总共 {1}",
"대상 디스크: 알 수 없음": "目标磁盘: 未知",
"대상 폴더 열기": "打开所在文件夹",
"더 보기": "更多",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
@ -368,6 +386,8 @@
"덴마크어": "丹麦语",
"독일어": "德语",
"돌아가기": "返回",
"동기화 시간": "同步时间",
"동기화 시간 (계속 / 시간 지정)": "同步时间 (持续 / 指定时间)",
"동시 MKV 변환 수": "同时转换 MKV 数",
"동시 다운로드 수": "同时下载数",
"동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)",
@ -386,6 +406,7 @@
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "要选择视频并在视频旁的 jj_mkv 文件夹中制作 MKV,\n需要“所有文件访问权限”。",
"동영상을 끌어다 놓으면 목록에 추가됩니다": "拖放视频即可加入列表",
"동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)",
"동작 중 · {0} 까지": "运行中 · 直到 {0}",
"되살리기": "恢复",
"두 창": "双窗格",
"두 창 배치": "双窗格排列",
@ -398,6 +419,7 @@
"러시아어": "俄语",
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
"루마니아어": "罗马尼亚语",
"마지막": "上次",
"마지막 인코딩 설정을 기억합니다": "记住上次的编码设置",
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
"말레이어": "马来语",
@ -405,9 +427,15 @@
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
"맞추기 (흐린 배경)": "适应(模糊背景)",
"맞추는 중…": "正在同步…",
"맞출 것 {0}개{1}": "待同步 {0} 个{1}",
"맞출 것 없음 (원본과 대상이 같습니다)": "无需同步 (源与目标一致)",
"맞출 것을 세는 중…": "正在统计待同步项…",
"맞춤": "已同步",
"매번 고르기": "每次选择",
"매일 종일": "每天 全天",
"멈춤": "已停止",
"멈춤 · {0}": "已停止 · {0}",
"모니터링": "监控",
"모델 내려받는 중 {0}%": "正在下载模型 {0}%",
"모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})",
@ -418,9 +446,11 @@
"모두 지우기": "全部清除",
"모두 초기화": "全部重置",
"모든 작업 취소": "取消所有任务",
"목": "四",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表",
"목록에서 제거": "从列表中移除",
"목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)",
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
"몽골어": "蒙古语",
"무한": "无限制",
@ -428,6 +458,7 @@
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
"미얀마어": "缅甸语",
"미지정": "未指定",
"바꾸기": "更改",
"바꾸면 바로 저장됩니다": "更改后立即保存",
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
"바뀐 날": "修改时间",
@ -448,6 +479,7 @@
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
"받음 {0}": "已接收 {0}",
"밝기": "亮度",
"밤 0~6시": "夜间 0~6 点",
"방법: {0}": "方式: {0}",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)",
"백그라운드로": "转到后台",
@ -470,8 +502,14 @@
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
"복사": "复制",
"복사 (붙여넣기로)": "复制 (用于粘贴)",
"복사 · rsync": "复制 · rsync",
"복사 · rsync 로 이동": "移到 复制 · rsync",
"복사 · rsync 목록으로 옮겼습니다.": "已移到 复制 · rsync 列表。",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "复制 · 移动需在双窗格中使用 (窗格布局 > 双窗格)。",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制 · 移动的项目 (点按,或用右侧圆圈标记)。",
"복사 끝 ({0}): {1}개 → {2}": "复制完成 ({0}): {1} 个 → {2}",
"복사 모니터링": "复制监控",
"복사 실패 ({0}): {1}": "复制失败 ({0}): {1}",
"복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址",
"복사하는 중": "正在复制",
"복사할까요?": "复制吗?",
@ -523,6 +561,7 @@
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "播放所选文件: 当前查看的视频(多个请在列表中勾选)",
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "播放所选文件: 按列表顺序连续播放勾选的 {0} 个视频",
"선택한 파일 재생{0}": "播放所选文件{0}",
"설정 반영 후 실행": "应用后运行",
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。",
"설치 계속": "继续安装",
"설치 실패": "安装失败",
@ -537,8 +576,10 @@
"세로 4:5": "竖屏 4:5",
"세로 9:16": "竖屏 9:16",
"세로 고정": "锁定竖屏",
"속도 제한": "速度限制",
"속도 제한 (KB/s)": "速度限制 (KB/s)",
"속도 제한 {0} KB/s": "速度限制 {0} KB/s",
"수": "三",
"수정": "编辑",
"수정 · 이동": "编辑 · 移动",
"수정한 날짜": "修改日期",
@ -551,6 +592,7 @@
"스웨덴어": "瑞典语",
"스타일": "样式",
"스페인어": "西班牙语",
"시간 지정 (cron)": "指定时间 (cron)",
"시간 형식 오류 {0}곳": "{0} 处时间格式错误",
"시간순 정렬": "按时间排序",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件",
@ -567,7 +609,9 @@
"실시간 동기화 추가": "添加实时同步",
"실시간 동기화: 대상 폴더": "实时同步: 目标文件夹",
"실시간 동기화: 원본 폴더": "实时同步: 源文件夹",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "没有实时同步。请 [添加],或在 复制 · rsync 标签中 [移到 lsync]。",
"실패": "失败",
"실패 · {0}": "失败 · {0}",
"실패: {0}": "失败: {0}",
"실패: {0}\n{1}": "失败: {0}\n{1}",
"실행": "运行",
@ -583,6 +627,7 @@
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
"아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}",
"아직 실행 안 함": "尚未运行",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。",
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
@ -595,6 +640,7 @@
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
"언어": "语言",
"언어 추가": "添加语言",
@ -620,6 +666,7 @@
"영어": "英语",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (归档 · 详细 · 进度 · 所有者 · 组)。移动时自动加上 --remove-source-files",
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE",
"예: 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}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})",
@ -630,6 +677,8 @@
"옮겼습니다.": "已移动。",
"옮기는 중": "正在移动",
"옮길까요?": "移动吗?",
"옵션": "选项",
"옵션을 저장했습니다": "已保存选项",
"완료": "完成",
"완료 정리": "清理已完成",
"완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}",
@ -654,10 +703,12 @@
"원본과 대상이 서로 안에 있습니다": "源与目标互相包含",
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
"원본의 {0}배": "原始的 {0} 倍",
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。",
"원어 (영상 속 언어)": "原语言(视频中的语言)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
"원어: {0} ({1})": "原语言: {0}({1})",
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
"월": "一",
"웹 브라우저": "网页浏览器",
"웹 브라우저 (지금 여기)": "网页浏览器(当前位置)",
"웹 브라우저 (홈 주소)": "网页浏览器(主页)",
@ -687,6 +738,7 @@
"이 버전 건너뛰기": "跳过此版本",
"이 버전에는 Android 용 APK 가 없습니다.": "此版本没有 Android 版 APK。",
"이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。",
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
"이 언어 지우기": "删除此语言",
"이 이동 버튼 지우기": "删除此移动按钮",
@ -738,6 +790,7 @@
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
"인터넷 자막 찾기": "在线查找字幕",
"인터넷 자막 찾기{0}": "在线查找字幕{0}",
"일": "日",
"일반": "常规",
"일본어": "日语",
"일시정지": "暂停",
@ -846,6 +899,7 @@
"종료할까요?": "要退出吗?",
"종류 (확장자)": "类型 (扩展名)",
"좌우": "左右",
"주말 종일": "周末 全天",
"주소": "地址",
"주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)",
"준비 중": "准备中",
@ -870,6 +924,7 @@
"즐겨찾기 추가…": "添加书签…",
"즐겨찾기 추가됨": "已添加书签",
"즐겨찾기 표시줄": "书签栏",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
"지금 맞추기": "立即同步",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
"지금 설치": "立即安装",
@ -925,6 +980,8 @@
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)",
"취소됨": "已取消",
"취소했습니다.": "已取消。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
"코덱": "编解码器",
"크기": "大小",
@ -936,6 +993,7 @@
"탐색기에서 연 동영상을 재생할 창": "播放从资源管理器打开的视频的窗口",
"태국어": "泰语",
"터키어": "土耳其语",
"토": "六",
"토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载",
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
"트랙 {0}": "轨道 {0}",
@ -951,6 +1009,7 @@
"파일 이름": "文件名",
"파일 탐색기": "文件浏览器",
"파일 탐색기 (지금 여기)": "文件浏览器 (当前位置)",
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "文件浏览器中间的 [监控] 按钮: 记住复制 · 移动并显示进度 · 目标磁盘容量 · 选项,可修改选项后重新运行 · 继续,或移到 lsync (复制 · rsync 与 lsync 分开显示)。",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)",
"파일 폴더": "文件夹",
"파일 확인 중…": "正在检查文件…",
@ -965,6 +1024,7 @@
"편집 목록에 있음": "已在编辑列表中",
"편집 목록에 추가": "加入编辑列表",
"편집 목록에 추가 (MKV 만들기 대상)": "加入编辑列表(制作 MKV 的对象)",
"평일 9~18시": "工作日 9~18 点",
"포르투갈어": "葡萄牙语",
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)",
"폴더": "文件夹",
@ -982,6 +1042,7 @@
"폴더가 들어 있을 때": "包含文件夹时",
"폴더가 아닙니다: {0}": "不是文件夹: {0}",
"폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}",
"폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。",
"폴란드어": "波兰语",
"표시 (여러 개 고르기)": "标记 (多选)",
"표시 이름": "显示名称",
@ -1018,6 +1079,7 @@
"허용했으면 다시 확인": "允许后重新检查",
"헝가리어": "匈牙利语",
"현재 방식": "当前方式",
"현재 유지": "保持现状",
"현재 페이지": "当前页面",
"현재 페이지 추가": "添加当前页面",
"형식 오류": "格式错误",
@ -1025,6 +1087,7 @@
"홈 주소": "主页地址",
"홈 화면": "主屏幕",
"홈 화면 ({0}) · 환경 설정에서 바꿈": "主屏幕({0})· 在设置中更改",
"화": "二",
"화면": "显示",
"화면 · 색 보정": "画面 · 色彩",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。",

View File

@ -0,0 +1,83 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/copy_center.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/schedule_editor.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 모니터링 화면 (복사 진행 중 · lsync · 일정 격자) 을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('모니터링 화면 그림', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_monshot_');
final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업');
for (var i = 0; i < 6; i++) {
File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv'))
..createSync(recursive: true)
..writeAsBytesSync(List.filled(200 * 1024, i));
}
Directory(dst).createSync();
final c = AppController(PlatformServices.create());
c.settings
..copyMonitor = true
..liveSyncPairs = [
LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true),
];
final live = LiveSync(c)..start();
final center = CopyCenter.of(c);
final t = await tester.runAsync(() => center.remember([p.join(src, 'A'), p.join(src, 'B')], dst));
await center.update(t!.copyWith(bandwidthKBps: 300));
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
Future<void> shot(String name) async {
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await tester.runAsync(() => boundary.toImage());
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
}
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
}
await tester.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => RepaintBoundary(key: key, child: child),
home: MonitorPage(c: c),
));
await tester.runAsync(() => center.start(center.tasks.single));
await wait(2000);
await shot('monitor_copy');
await tester.tap(find.text('lsync (실시간 동기화)'));
await wait(5000);
await shot('monitor_lsync');
// 일정 격자
final ctx = tester.element(find.byType(MonitorPage));
final f = editSchedule(ctx, const ['0 9-17 * * 1-5', '0 22-23 * * 0,6']);
await wait(800);
await shot('monitor_schedule');
Navigator.of(ctx).pop();
await f;
center.cancel(center.tasks.single.id);
await wait(500);
live.dispose();
tester.view.reset();
tmp.deleteSync(recursive: true);
});
}

168
lib/app/copy_center.dart Normal file
View File

@ -0,0 +1,168 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../core/file_ops.dart';
import '../core/sync_tools.dart';
import '../l10n/tr.dart';
import '../platform/windows/windows_usage.dart';
import 'app_controller.dart';
import 'live_sync.dart';
import 'settings.dart';
import 'transfer_job.dart';
/// 복사 · 이동 모니터링 (파일 탐색기 > 모니터링 > 복사 · rsync):
/// 복사한 원본 → 대상을 기억 (설정의 copyTasks) 해 진행 · 결과를 보여 주고, 옵션을 고쳐 다시 실행하고,
/// 실시간 동기화 (lsync) 로 옮기거나 lsync 에서 가져온다.
class CopyCenter extends ChangeNotifier {
final AppController c;
CopyCenter(this.c);
static CopyCenter? _instance;
static CopyCenter of(AppController c) => _instance != null && _instance!.c == c ? _instance! : (_instance = CopyCenter(c));
/// 작업 id → 지금 (또는 마지막) 실행
final jobs = <String, TransferJob>{};
List<CopyTask> get tasks => c.settings.copyTasks;
bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished;
bool get anyRunning => jobs.values.any((j) => !j.finished);
/// 새 작업: 지금 설정의 방법 · 옵션으로
CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
final s = c.settings;
final hasDir = sources.any(FileSystemEntity.isDirectorySync);
var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile));
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
return CopyTask(
id: '${DateTime.now().microsecondsSinceEpoch}',
sources: sources,
dest: dest,
move: move,
contents: contents,
method: m.name,
options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions,
once: s.copyRunMode == 'once',
bandwidthKBps: s.copyBandwidthKBps,
);
}
/// 같은 복사를 기억해 두었으면 그것 (그 옵션을 쓴다), 없으면 새로 기억
Future<CopyTask> remember(List<String> sources, String dest, {bool move = false}) async {
final t = fresh(sources, dest, move: move);
final old = tasks.where((x) => x.key == t.key).firstOrNull;
if (old != null) return old;
await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]);
notifyListeners();
return t;
}
Future<void> update(CopyTask t) async {
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]);
notifyListeners();
}
Future<void> remove(String id) async {
cancel(id);
jobs.remove(id);
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) if (y.id != id) y]);
notifyListeners();
}
void cancel(String id) => jobs[id]?.cancel();
/// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]).
Future<TransferJob?> start(CopyTask t, {String? rsyncExe}) async {
if (isRunning(t.id)) return jobs[t.id];
final method = CopyMethod.of(t.method);
final exe = method == CopyMethod.rsync ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null;
if (method == CopyMethod.rsync && exe == null) return null;
final job = TransferJob(
sources: t.sources,
dest: t.dest,
move: t.move,
method: method,
options: t.options,
once: t.once,
bandwidthKBps: t.bandwidthKBps,
rsyncExe: exe,
contents: t.contents,
);
jobs[t.id] = job;
job.addListener(notifyListeners);
notifyListeners();
unawaited(_runAndRecord(t, job, method));
return job;
}
Future<void> _runAndRecord(CopyTask t, TransferJob job, CopyMethod method) async {
await job.run();
final e = job.error;
final cur = tasks.where((x) => x.id == t.id).firstOrNull;
if (cur != null) {
await update(cur.copyWith(
lastRun: DateTime.now().toIso8601String(),
lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed',
lastMessage: e == null ? '' : '$e',
lastFiles: job.allDone,
));
}
c.note(e == null
? trf('복사 끝 ({0}): {1}개 → {2}', [method.label, t.sources.length, t.dest])
: trf('복사 실패 ({0}): {1}', [method.label, e]));
notifyListeners();
}
/// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다.
Future<int> toLiveSync(CopyTask t) async {
final dirs = t.sources.where(FileSystemEntity.isDirectorySync).toList();
if (dirs.isEmpty) return 0;
final pairs = [
for (final d in dirs) LiveSyncPair(d, t.contents ? t.dest : p.join(t.dest, p.basename(d)), method: t.method),
];
final rest = t.sources.where((s) => !dirs.contains(s)).toList();
await c.updateSettings((x) => x
..liveSyncPairs = [
...x.liveSyncPairs,
for (final n in pairs)
if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n,
]
..copyTasks = [
for (final y in x.copyTasks)
if (y.id != t.id)
y
else if (rest.isNotEmpty)
CopyTask(id: y.id, sources: rest, dest: y.dest, method: y.method, options: y.options, bandwidthKBps: y.bandwidthKBps),
]);
notifyListeners();
return pairs.length;
}
/// lsync 에서 빼서 복사 목록으로 (rsync 로, 원본 "안의 것" → 대상)
Future<CopyTask> fromLiveSync(LiveSyncPair x) async {
final s = c.settings;
final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method;
final t = fresh([x.source], x.target, contents: true, method: m);
await c.updateSettings((y) => y
..liveSyncPairs = [for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o]
..copyTasks = [t, ...y.copyTasks]);
notifyListeners();
return t;
}
}
/// 디스크 남은 용량 · 전체 용량 (바이트). 알 수 없으면 null.
Future<(int, int)?> diskSpace(String path) async {
try {
if (Platform.isWindows) return WindowsUsage().disk(path);
if (Platform.isAndroid) {
final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod<List<Object?>>('diskSpace', {'path': path});
if (r != null && r.length == 2) return ((r[0] as num).toInt(), (r[1] as num).toInt());
}
} catch (_) {}
return null;
}

View File

@ -4,6 +4,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../core/cron_window.dart';
import '../core/file_ops.dart';
import '../core/sync_tools.dart';
import '../l10n/tr.dart';
@ -46,22 +49,84 @@ class LiveSync extends ChangeNotifier {
/// 쌍마다 마지막 결과 (화면 표시용): (시각, 글)
final status = <String, (DateTime, String)>{};
/// 쌍마다 아직 맞추지 않은 것 (원본 기준 상대 경로, 지울 것은 "− " 를 앞에). 바뀌면 자동으로 다시 센다.
final pending = <String, List<String>>{};
String _sig = '';
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
Timer? _clock;
void start() {
instance = this;
c.addListener(_apply);
_apply();
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock());
}
@override
void dispose() {
c.removeListener(_apply);
_clock?.cancel();
_stopAll();
if (instance == this) instance = null;
super.dispose();
}
/// 지금 동작 시간인지 (일정이 없으면 늘)
static bool activeNow(LiveSyncPair x, [DateTime? now]) => scheduleActive(x.schedule, now ?? DateTime.now());
void _onClock() {
for (final x in c.settings.liveSyncPairs.where((x) => x.enabled)) {
if (activeNow(x) && (pending[keyOf(x)]?.isNotEmpty ?? false)) _schedule(x);
}
notifyListeners(); // 남은 시간 표시
}
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async {
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final names = <String>{};
await for (final e in Directory(src).list(followLinks: false).handleError((_) {})) {
if (out.length >= limit) return;
final name = p.basename(e.path);
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name';
final t = p.join(dst, name);
if (e is Directory) {
await walk(e.path, t, r);
} else if (e is File) {
final st = await e.stat();
final tf = File(t);
if (!await tf.exists()) {
out.add(r);
continue;
}
final ts = await tf.stat();
if (ts.size != st.size || ts.modified.difference(st.modified).inSeconds.abs() > 2) out.add(r);
}
}
if (x.delete && await Directory(dst).exists()) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
final name = p.basename(e.path);
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
}
}
}
if (await Directory(x.source).exists()) await walk(x.source, x.target, '');
return out;
}
Future<void> refreshPending(LiveSyncPair x) async {
try {
pending[keyOf(x)] = await diff(x);
} catch (_) {}
notifyListeners();
}
static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}';
void _stopAll() {
@ -110,10 +175,14 @@ class LiveSync extends ChangeNotifier {
notifyListeners();
}
/// 바뀜 · 정해진 간격: 다른 점을 다시 세고, 동작 시간이면 3초 뒤 맞춘다 (모아서)
void _schedule(LiveSyncPair x) {
final k = keyOf(x);
_pending[k]?.cancel();
_pending[k] = Timer(const Duration(seconds: 3), () => syncNow(x));
_pending[k] = Timer(const Duration(seconds: 3), () async {
await refreshPending(x);
if (activeNow(x) && (pending[k]?.isNotEmpty ?? false)) await syncNow(x);
});
}
bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x));
@ -136,6 +205,7 @@ class LiveSync extends ChangeNotifier {
CopyMethod.robocopy => await _robocopy(x, s),
};
status[k] = (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
pending[k] = await diff(x);
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} catch (e) {
status[k] = (DateTime.now(), trf('실패: {0}', [e]));

View File

@ -40,13 +40,20 @@ class LiveSyncPair {
/// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE)
final bool delete;
final bool enabled;
const LiveSyncPair(this.source, this.target, {this.method = 'builtin', this.delete = false, this.enabled = true});
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled}) =>
LiveSyncPair(source, target, method: method ?? this.method, delete: delete ?? this.delete, enabled: enabled ?? this.enabled);
/// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘)
final List<String> schedule;
const LiveSyncPair(this.source, this.target,
{this.method = 'builtin', this.delete = false, this.enabled = true, this.schedule = const []});
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled, List<String>? schedule}) => LiveSyncPair(source, target,
method: method ?? this.method,
delete: delete ?? this.delete,
enabled: enabled ?? this.enabled,
schedule: schedule ?? this.schedule);
Map<String, Object?> toJson() =>
{'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled};
{'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled, 'schedule': schedule};
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
j['source'] as String,
@ -54,6 +61,108 @@ class LiveSyncPair {
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
delete: j['delete'] == true,
enabled: j['enabled'] != false,
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
);
}
/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시.
class CopyTask {
final String id;
final List<String> sources;
final String dest;
final bool move;
/// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/)
final bool contents;
/// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s)
final String method;
final String options;
final bool once;
final int bandwidthKBps;
/// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수
final String lastRun;
final String lastResult;
final String lastMessage;
final int lastFiles;
const CopyTask({
required this.id,
required this.sources,
required this.dest,
this.move = false,
this.contents = false,
this.method = 'builtin',
this.options = '',
this.once = false,
this.bandwidthKBps = 0,
this.lastRun = '',
this.lastResult = '',
this.lastMessage = '',
this.lastFiles = 0,
});
/// 같은 복사인지 (원본들 · 대상 · 이동)
String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest';
CopyTask copyWith({
String? method,
String? options,
bool? once,
int? bandwidthKBps,
bool? move,
String? lastRun,
String? lastResult,
String? lastMessage,
int? lastFiles,
}) =>
CopyTask(
id: id,
sources: sources,
dest: dest,
move: move ?? this.move,
contents: contents,
method: method ?? this.method,
options: options ?? this.options,
once: once ?? this.once,
bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps,
lastRun: lastRun ?? this.lastRun,
lastResult: lastResult ?? this.lastResult,
lastMessage: lastMessage ?? this.lastMessage,
lastFiles: lastFiles ?? this.lastFiles,
);
Map<String, Object?> toJson() => {
'id': id,
'sources': sources,
'dest': dest,
'move': move,
'contents': contents,
'method': method,
'options': options,
'once': once,
'bandwidthKBps': bandwidthKBps,
'lastRun': lastRun,
'lastResult': lastResult,
'lastMessage': lastMessage,
'lastFiles': lastFiles,
};
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
id: '${j['id']}',
sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'],
dest: '${j['dest']}',
move: j['move'] == true,
contents: j['contents'] == true,
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
options: j['options'] as String? ?? '',
once: j['once'] == true,
bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0,
lastRun: j['lastRun'] as String? ?? '',
lastResult: j['lastResult'] as String? ?? '',
lastMessage: j['lastMessage'] as String? ?? '',
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
);
}
@ -206,6 +315,10 @@ class AppSettings {
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 복사 모니터링: 파일 탐색기 가운데 [모니터링] 버튼, 복사 · 이동을 기억해 다시 실행 · 옵션 고치기
bool copyMonitor = false;
List<CopyTask> copyTasks = [];
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
@ -301,6 +414,8 @@ class AppSettings {
'rsyncPath': rsyncPath,
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
'liveSyncIntervalSec': liveSyncIntervalSec,
'copyMonitor': copyMonitor,
'copyTasks': [for (final x in copyTasks) x.toJson()],
'explorerHistory': explorerHistory,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
@ -403,6 +518,11 @@ class AppSettings {
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
]
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
..copyMonitor = j['copyMonitor'] == true
..copyTasks = [
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
]
..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x']
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''

View File

@ -27,6 +27,9 @@ class TransferJob extends ChangeNotifier {
/// rsync 실행 파일 (rsync 방법일 때)
final String? rsyncExe;
/// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사.
final bool contents;
TransferJob({
required this.sources,
required this.dest,
@ -36,6 +39,7 @@ class TransferJob extends ChangeNotifier {
this.once = false,
this.bandwidthKBps = 0,
this.rsyncExe,
this.contents = false,
});
// ── 진행 상태 ──
@ -77,6 +81,11 @@ class TransferJob extends ChangeNotifier {
Process? _proc;
FileOps? _ops;
final _done = Completer<void>();
/// 끝날 때 (성공 · 실패 · 취소)
Future<void> get done => _done.future;
void cancel() {
cancelled = true;
_ops?.cancel();
@ -120,6 +129,7 @@ class TransferJob extends ChangeNotifier {
} finally {
finished = true;
_tick();
if (!_done.isCompleted) _done.complete();
}
}
@ -132,8 +142,13 @@ class TransferJob extends ChangeNotifier {
_tick();
});
_tick();
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
made.addAll(r);
if (contents && FileSystemEntity.isDirectorySync(sources[i])) {
await _ops!.mirror(sources[i], dest);
made.add(dest);
} else {
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
made.addAll(r);
}
filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우
}
index = sources.length;
@ -175,14 +190,15 @@ class TransferJob extends ChangeNotifier {
dest: dest,
bandwidthKBps: bandwidthKBps,
move: move,
windows: windows),
windows: windows,
contents: contents),
utf8,
(chunk) {
for (final f in out.feed(chunk)) {
_addLog(f);
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 (안의 것 모드는 이름이 없어 지금 항목)
final first = f.split('/').first;
var k = idx.indexWhere((i) => p.basename(sources[i]) == first);
var k = contents ? -1 : idx.indexWhere((i) => p.basename(sources[i]) == first);
if (k < 0) k = names.indexOf(f);
final at = k < 0 ? index : idx[k];
if (at > index) index = at;
@ -230,6 +246,7 @@ class TransferJob extends ChangeNotifier {
dest: dest,
bandwidthKBps: bandwidthKBps,
move: move,
contents: contents,
);
_tick();
for (final args in runs) {

158
lib/core/cron_window.dart Normal file
View File

@ -0,0 +1,158 @@
/// 실시간 동기화의 동작 시간 (cron 과 같은 글: "분 시 일 월 요일").
///
/// 시간 창으로 쓴다: 시 · 일 · 월 · 요일이 맞는 "그 시간 동안" 동기화한다 (분은 무시, 1시간 단위).
/// 여러 줄이면 하나라도 맞으면 동작. 빈 목록 = 계속 (앱이 켜져 있는 동안 늘).
///
/// 예: "0 9-17 * * 1-5" 평일 9시~17시 59분, "0 0-5 * * *" 매일 0시~5시 59분, "0 */2 * * 0,6" 주말 2시간마다 1시간씩.
library;
/// cron 글 한 줄
class CronExpr {
final Set<int> minutes, hours, days, months, weekdays;
final bool dayStar, weekdayStar;
final String text;
CronExpr._(this.text, this.minutes, this.hours, this.days, this.months, this.weekdays, this.dayStar, this.weekdayStar);
static const _dows = {'SUN': 0, 'MON': 1, 'TUE': 2, 'WED': 3, 'THU': 4, 'FRI': 5, 'SAT': 6};
static const _mons = {
'JAN': 1, 'FEB': 2, 'MAR': 3, 'APR': 4, 'MAY': 5, 'JUN': 6, //
'JUL': 7, 'AUG': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DEC': 12,
};
/// 잘못된 글이면 FormatException
static CronExpr parse(String text) {
final f = text.trim().split(RegExp(r'\s+'));
if (f.length != 5) throw FormatException('cron 은 다섯 칸입니다 (분 시 일 월 요일)', text);
final wd = _field(f[4], 0, 7, _dows).map((d) => d == 7 ? 0 : d).toSet();
return CronExpr._(text.trim(), _field(f[0], 0, 59), _field(f[1], 0, 23), _field(f[2], 1, 31), _field(f[3], 1, 12, _mons),
wd, f[2] == '*', f[4] == '*');
}
static CronExpr? tryParse(String text) {
try {
return parse(text);
} on FormatException {
return null;
}
}
static Set<int> _field(String s, int lo, int hi, [Map<String, int> names = const {}]) {
int num(String x) {
final n = names[x.toUpperCase()] ?? int.tryParse(x);
if (n == null || n < lo || n > hi) throw FormatException('범위 밖 값: $x ($lo~$hi)');
return n;
}
final out = <int>{};
for (final part in s.split(',')) {
final stepParts = part.split('/');
if (stepParts.length > 2) throw FormatException('잘못된 칸: $part');
final step = stepParts.length == 2 ? int.tryParse(stepParts[1]) : 1;
if (step == null || step < 1) throw FormatException('잘못된 간격: $part');
final range = stepParts[0];
int a, b;
if (range == '*') {
a = lo;
b = hi;
} else if (range.contains('-')) {
final r = range.split('-');
if (r.length != 2) throw FormatException('잘못된 범위: $part');
a = num(r[0]);
b = num(r[1]);
if (b < a) throw FormatException('거꾸로 된 범위: $part');
} else {
a = num(range);
b = stepParts.length == 2 ? hi : a;
}
for (var v = a; v <= b; v += step) {
out.add(v);
}
}
return out;
}
/// [t] 가 이 줄의 시간 창 안인지 (시 · 일 · 월 · 요일. cron 처럼 일 · 요일을 둘 다 정하면 둘 중 하나만 맞아도 됨)
bool activeAt(DateTime t) {
if (!hours.contains(t.hour) || !months.contains(t.month)) return false;
final dom = days.contains(t.day), dow = weekdays.contains(t.weekday % 7);
if (dayStar || weekdayStar) return dom && dow;
return dom || dow;
}
/// 시간 격자로 나타낼 수 있는지 (일 · 월이 * 일 때)
bool get gridable => dayStar && months.length == 12;
}
/// 일정 (cron 여러 줄). 빈 목록 = 계속
bool scheduleActive(List<String> lines, DateTime t) {
if (lines.isEmpty) return true;
for (final l in lines) {
final e = CronExpr.tryParse(l);
if (e != null && e.activeAt(t)) return true;
}
return false;
}
/// 다음에 동작이 시작되는 때 (지금 동작 중이면 null, 8일 안에 없으면 null)
DateTime? scheduleNextStart(List<String> lines, DateTime now) {
if (lines.isEmpty || scheduleActive(lines, now)) return null;
var t = DateTime(now.year, now.month, now.day, now.hour).add(const Duration(hours: 1));
for (var i = 0; i < 24 * 8; i++, t = t.add(const Duration(hours: 1))) {
if (scheduleActive(lines, t)) return t;
}
return null;
}
/// 지금 동작 중이면 끝나는 때
DateTime? scheduleEnd(List<String> lines, DateTime now) {
if (lines.isEmpty || !scheduleActive(lines, now)) return null;
var t = DateTime(now.year, now.month, now.day, now.hour).add(const Duration(hours: 1));
for (var i = 0; i < 24 * 8; i++, t = t.add(const Duration(hours: 1))) {
if (!scheduleActive(lines, t)) return t;
}
return null;
}
/// 요일 (0 = 일요일) × 시 (0~23) 격자 칸: 요일 * 24 + 시
Set<int> gridFromCron(List<String> lines) {
final out = <int>{};
for (final l in lines) {
final e = CronExpr.tryParse(l);
if (e == null || !e.gridable) continue;
for (final d in e.weekdays) {
for (final h in e.hours) {
out.add(d * 24 + h);
}
}
}
return out;
}
/// 격자 → cron 줄 (같은 시간대를 가진 요일끼리 한 줄)
List<String> cronFromGrid(Set<int> cells) {
if (cells.length == 7 * 24) return const ['0 * * * *'];
final byHours = <String, List<int>>{};
for (var d = 0; d < 7; d++) {
final hours = [for (var h = 0; h < 24; h++) if (cells.contains(d * 24 + h)) h];
if (hours.isEmpty) continue;
byHours.putIfAbsent(_ranges(hours, 23), () => []).add(d);
}
return [for (final e in byHours.entries) '0 ${e.key} * * ${_ranges(e.value, 6)}'];
}
/// [1,2,3,5] → "1-3,5" (전부면 *)
String _ranges(List<int> v, int max) {
if (v.length == max + 1) return '*';
final parts = <String>[];
var i = 0;
while (i < v.length) {
var j = i;
while (j + 1 < v.length && v[j + 1] == v[j] + 1) {
j++;
}
parts.add(i == j ? '${v[i]}' : '${v[i]}-${v[j]}');
i = j + 1;
}
return parts.join(',');
}

View File

@ -66,13 +66,15 @@ List<String> rsyncArgs({
int bandwidthKBps = 0,
bool move = false,
bool windows = true,
bool contents = false,
}) {
final opts = splitOptions(options);
return [
...opts,
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
if (move && !opts.contains('--remove-source-files')) '--remove-source-files',
for (final s in sources) toCygwinPath(_noTrailingSlash(s), windows: windows),
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',
];
}
@ -90,6 +92,7 @@ List<List<String>> robocopyRuns({
required String dest,
int bandwidthKBps = 0,
bool move = false,
bool contents = false,
}) {
final opts = splitOptions(options);
final extra = [
@ -97,7 +100,7 @@ List<List<String>> robocopyRuns({
if (move && !opts.any((o) => o.toUpperCase().startsWith('/MOV'))) '/MOVE',
];
final runs = <List<String>>[
for (final f in folders) [f, p.join(dest, p.basename(f)), ...opts, ...extra],
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
];
final byDir = <String, List<String>>{};
for (final f in files) {

View File

@ -12,6 +12,7 @@ import '../l10n/tr.dart';
import '../platform/windows/rsync_installer.dart';
import 'folder_picker.dart';
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
/// 환경 설정 > 파일 탐색기 의 복사 · 이동 · 동기화 부분:
@ -195,23 +196,34 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
child: Text(tr('고르기')),
),
),
SwitchListTile(
value: s.copyMonitor,
onChanged: (v) => c.updateSettings((x) => x.copyMonitor = v),
title: Text(tr('복사 모니터링')),
subtitle: Text(tr('파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, '
'옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).')),
),
_LiveSyncTile(c: c),
]);
}
}
/// 실시간 동기화 (lsyncd 처럼): 폴더 쌍 목록 · 추가 · 켜기 / 끄기 · 지금 맞추기 · 지우기
/// 실시간 동기화 쌍 추가 (원본 · 대상 폴더 고르기 → 방법 · 지우기 · 일정)
Future<void> addLiveSyncPairDialog(BuildContext context, AppController c) => _LiveSyncTile._add(context, c);
/// 실시간 동기화 (lsyncd 처럼): 폴더 쌍 목록 · 추가 · 켜기 / 끄기 · 일정 · 지금 맞추기 · 지우기
class _LiveSyncTile extends StatelessWidget {
final AppController c;
const _LiveSyncTile({required this.c});
Future<void> _add(BuildContext context) async {
static Future<void> _add(BuildContext context, AppController c) async {
final src = await pickFolder(context, tr('실시간 동기화: 원본 폴더'));
if (src == null || !context.mounted) return;
final dst = await pickFolder(context, tr('실시간 동기화: 대상 폴더'));
if (dst == null || !context.mounted) return;
var method = 'builtin';
var method = copyMethodAvailable(CopyMethod.rsync, c.settings) ? 'rsync' : 'builtin';
var delete = false;
var schedule = <String>[];
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
@ -232,6 +244,19 @@ class _LiveSyncTile extends StatelessWidget {
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: WeekGridPreview(lines: schedule, width: 96),
title: Text(scheduleSummary(schedule)),
subtitle: Text(tr('동기화 시간 (계속 / 시간 지정)')),
trailing: TextButton(
onPressed: () async {
final r = await editSchedule(ctx, schedule);
if (r != null) set(() => schedule = r);
},
child: Text(tr('바꾸기')),
),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -241,7 +266,8 @@ class _LiveSyncTile extends StatelessWidget {
),
);
if (ok != true) return;
await c.updateSettings((x) => x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src, dst, method: method, delete: delete)]);
await c.updateSettings(
(x) => x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src, dst, method: method, delete: delete, schedule: schedule)]);
}
@override
@ -255,7 +281,7 @@ class _LiveSyncTile extends StatelessWidget {
? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.')
: trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])),
trailing: TextButton.icon(
onPressed: () => _add(context),
onPressed: () => _add(context, c),
icon: const Icon(Icons.add, size: 18),
label: Text(tr('추가')),
),
@ -270,11 +296,23 @@ class _LiveSyncTile extends StatelessWidget {
subtitle: Text([
tr(CopyMethod.of(pairs[i].method).label),
if (pairs[i].delete) tr('지우기 포함'),
scheduleSummary(pairs[i].schedule),
if (live?.isRunning(pairs[i]) ?? false) tr('맞추는 중…'),
if (live?.status[LiveSync.keyOf(pairs[i])] case final st?)
'${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
].join(' · ')),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(
tooltip: tr('동기화 시간 (계속 / 시간 지정)'),
icon: const Icon(Icons.schedule, size: 18),
onPressed: () async {
final r = await editSchedule(context, pairs[i].schedule);
if (r == null) return;
await c.updateSettings((x) => x.liveSyncPairs = [
for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k],
]);
},
),
IconButton(
tooltip: tr('지금 맞추기'),
icon: const Icon(Icons.sync, size: 18),

View File

@ -9,6 +9,7 @@ import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/live_sync.dart';
import '../app/transfer_job.dart';
import '../core/sync_tools.dart';
@ -17,6 +18,7 @@ import '../core/playlist.dart' show isVideoFile;
import '../platform/android/android_storage.dart';
import 'app_actions.dart';
import 'explorer_look.dart';
import 'monitor_page.dart';
import 'player_page.dart';
import 'rsync_setup.dart';
import 'theme.dart';
@ -61,6 +63,7 @@ enum ExplorerButton {
up(Icons.arrow_upward, '상위 폴더'),
refresh(Icons.refresh, '새로 고침'),
select(Icons.checklist, '선택'),
monitor(Icons.monitor_heart_outlined, '모니터링'),
play(Icons.play_circle_outline, '재생'),
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
@ -527,6 +530,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _goTo(pane, up);
case ExplorerButton.select:
_setSelecting(!_selecting);
case ExplorerButton.monitor:
await MonitorPage.open(context, c);
case ExplorerButton.refresh:
pane.cache.clear();
for (final d in pane.expanded.toList()) {
@ -719,14 +724,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
TransferJob? _job;
/// 고른 것에 맞는 방법: 폴더가 하나라도 있으면 "폴더 복사 방법", 파일만이면 "파일 복사 방법"
CopyMethod _methodFor(List<String> sources) {
final s = c.settings;
final hasDir = sources.any(FileSystemEntity.isDirectorySync);
final m = CopyMethod.of(hasDir ? s.copyMethodFolder : s.copyMethodFile);
return copyMethodAvailable(m, s) ? m : CopyMethod.builtin;
}
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> _ensureRsync() async {
final s = c.settings;
@ -750,14 +747,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
_snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.'));
return false;
}
final method = _methodFor(sources);
// 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다
final center = CopyCenter.of(c);
final task = c.settings.copyMonitor
? await center.remember(sources, dest, move: move)
: center.fresh(sources, dest, move: move);
final method = CopyMethod.of(task.method);
if (!mounted) return false;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(move ? tr('이동') : tr('복사')),
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])}'
'\n\n${trf('방법: {0}', [tr(method.label)])}'
'${c.settings.copyBandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [c.settings.copyBandwidthKBps])}' : ''}'),
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
@ -770,19 +774,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
rsync = await _ensureRsync();
if (rsync == null || !mounted) return false;
}
final s = c.settings;
final job = TransferJob(
sources: sources,
dest: dest,
move: move,
method: method,
options: method == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions,
once: s.copyRunMode == 'once',
bandwidthKBps: s.copyBandwidthKBps,
rsyncExe: rsync,
);
final job = await center.start(task, rsyncExe: rsync);
if (job == null || !mounted) return false;
setState(() => _job = job);
await job.run();
await job.done;
final error = job.error;
if (!mounted) return false;
pane.marked.clear();
@ -1227,7 +1222,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
final bar = s.explorerToolbar == 'hidden'
? null
: _Toolbar(
buttons: ExplorerButton.fromSettings(s.explorerButtons),
// [모니터링] 은 환경 설정 > 복사 모니터링 을 켰을 때만
buttons: [
for (final b in ExplorerButton.fromSettings(s.explorerButtons))
if (b != ExplorerButton.monitor || s.copyMonitor) b,
],
vertical: side,
onPressed: _button,
enabled: (b) => switch (b) {

580
lib/ui/monitor_page.dart Normal file
View File

@ -0,0 +1,580 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/live_sync.dart';
import '../app/settings.dart';
import '../app/transfer_job.dart';
import '../core/file_ops.dart';
import '../core/sync_tools.dart';
import '../l10n/tr.dart';
import 'app_actions.dart';
import 'copy_sync_settings.dart' show addLiveSyncPairDialog;
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
class MonitorPage extends StatelessWidget {
final AppController c;
final int initialTab;
const MonitorPage({super.key, required this.c, this.initialTab = 0});
static Future<void> open(BuildContext context, AppController c, {int tab = 0}) => Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: c, initialTab: tab)));
@override
Widget build(BuildContext context) => DefaultTabController(
length: 2,
initialIndex: initialTab,
child: Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
Expanded(
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(icon: const Icon(Icons.copy_all_outlined, size: 18), text: tr('복사 · rsync')),
Tab(icon: const Icon(Icons.sync, size: 18), text: tr('lsync (실시간 동기화)')),
],
),
),
AppActions(c: c),
]),
),
Expanded(
child: TabBarView(children: [
_CopyTab(c: c),
_LiveTab(c: c),
]),
),
]),
),
);
}
String _hm(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return '';
String two(int x) => x.toString().padLeft(2, '0');
return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}';
}
/// 대상 디스크 남은 용량 / 전체
class _DiskInfo extends StatelessWidget {
final String path;
const _DiskInfo({required this.path});
@override
Widget build(BuildContext context) {
var dir = path;
while (!Directory(dir).existsSync() && p.dirname(dir) != dir) {
dir = p.dirname(dir);
}
return FutureBuilder<(int, int)?>(
future: diskSpace(dir),
builder: (_, s) {
final d = s.data;
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: const TextStyle(fontSize: 12, color: JjColors.textDim));
final (free, total) = d;
final used = total == 0 ? 0.0 : (total - free) / total;
return Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4),
Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), style: const TextStyle(fontSize: 12)),
const SizedBox(width: 8),
SizedBox(
width: 90,
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 6,
value: used,
color: used > 0.9 ? Colors.redAccent : JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
]);
},
);
}
}
/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일)
class TransferProgressLines extends StatelessWidget {
final TransferJob job;
const TransferProgressLines({super.key, required this.job});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: job,
builder: (_, _) {
final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1);
String pct(double v) => '${(v * 100).round()}%';
Widget line(String label, double v, String right) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border),
),
),
SizedBox(
width: 130,
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim))),
]),
);
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('파일 세는 중…') : trf('{0} 안의 파일', [p.basename(job.sources[cur])]), job.current,
'${pct(job.current)} · ${trf('파일 {0}/{1}', [job.filesDone[cur], job.filesTotal[cur]])}'),
]);
},
);
}
// ───────── 복사 · rsync ─────────
class _CopyTab extends StatelessWidget {
final AppController c;
const _CopyTab({required this.c});
@override
Widget build(BuildContext context) {
final center = CopyCenter.of(c);
return ListenableBuilder(
listenable: Listenable.merge([center, c]),
builder: (context, _) {
final tasks = center.tasks;
if (tasks.isEmpty) {
return Center(
child: Text(tr('기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.'),
style: const TextStyle(color: JjColors.textDim)),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: [for (final t in tasks) _CopyCard(key: ValueKey(t.id), c: c, task: t)],
);
},
);
}
}
class _CopyCard extends StatefulWidget {
final AppController c;
final CopyTask task;
const _CopyCard({super.key, required this.c, required this.task});
@override
State<_CopyCard> createState() => _CopyCardState();
}
class _CopyCardState extends State<_CopyCard> {
CopyCenter get center => CopyCenter.of(widget.c);
late String _method = widget.task.method;
late final _options = TextEditingController(text: widget.task.options);
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
late bool _once = widget.task.once;
bool get _dirty =>
_method != widget.task.method ||
_options.text.trim() != widget.task.options ||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
_once != widget.task.once;
@override
void dispose() {
_options.dispose();
_bw.dispose();
super.dispose();
}
Future<void> _run(CopyTask t) async {
String? exe;
if (t.method == 'rsync') {
exe = await rsyncExecutable(widget.c.settings);
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'))));
}
return;
}
}
await center.start(t, rsyncExe: exe);
}
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
Future<void> _save() async {
final t = widget.task.copyWith(
method: _method,
options: _options.text.trim(),
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
once: _once,
);
await center.update(t);
if (!mounted) return;
final running = center.isRunning(t.id);
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('옵션을 저장했습니다')),
content: Text(running
? tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
],
),
);
if (go != true) return;
if (running) {
final old = center.jobs[t.id]!;
old.cancel();
await old.done;
}
await _run(t);
}
Future<void> _toLive(CopyTask t) async {
final n = await center.toLiveSync(t);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n == 0 ? tr('폴더만 lsync 로 옮길 수 있습니다.') : trf('{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.', [n]))));
}
@override
Widget build(BuildContext context) {
final t = widget.task;
final job = center.jobs[t.id];
final running = job != null && !job.finished;
final s = widget.c.settings;
final (icon, color, status) = running
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
: switch (t.lastResult) {
'done' => (Icons.check_circle, Colors.greenAccent, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])),
'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])),
'cancelled' => (Icons.pause_circle, Colors.orangeAccent, trf('멈춤 · {0}', [_hm(t.lastRun)])),
_ => (Icons.schedule, JjColors.textDim, tr('아직 실행 안 함')),
};
final optsHint = switch (CopyMethod.of(_method)) {
CopyMethod.rsync => defaultRsyncOptions,
CopyMethod.robocopy => defaultRobocopyOptions,
CopyMethod.builtin => tr('(현재 방식은 옵션 없음)'),
};
return Card(
color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
'${t.sources.length == 1 ? t.sources.first : trf('{0} 외 {1}개', [p.basename(t.sources.first), t.sources.length - 1])}'
'${t.contents ? '/' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text('→ ${t.dest}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text('${t.move ? tr('이동') : tr('복사')} · ${tr(CopyMethod.of(t.method).label)}'),
),
]),
Padding(
padding: const EdgeInsets.only(left: 28, top: 2),
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(status, style: TextStyle(fontSize: 12, color: color)),
_DiskInfo(path: t.dest),
]),
),
if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 28, top: 4),
child: Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
),
if (job != null && (running || job.finished && job.error == null))
Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)),
const SizedBox(height: 8),
// 옵션 (고치면 [저장] → 지금 실행할지 묻기)
Padding(
padding: const EdgeInsets.only(left: 28),
child: Wrap(spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
DropdownButton<String>(
value: _method,
items: [
for (final m in CopyMethod.values)
if (copyMethodAvailable(m, s) || m.name == _method) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
],
onChanged: (v) => setState(() {
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : '';
}
_method = v!;
}),
),
SizedBox(
width: 320,
child: TextField(
controller: _options,
enabled: _method != 'builtin',
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
onChanged: (_) => setState(() {}),
),
),
SizedBox(
width: 130,
child: TextField(
controller: _bw,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('속도 제한'), suffixText: 'KB/s'),
onChanged: (_) => setState(() {}),
),
),
if (t.sources.length > 1 && _method == 'rsync')
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
]),
),
const SizedBox(height: 6),
Row(children: [
const Spacer(),
if (running)
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
else
FilledButton.icon(
onPressed: () => _run(t),
icon: const Icon(Icons.play_arrow, size: 18),
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
),
const SizedBox(width: 6),
TextButton.icon(
onPressed: running ? null : () => _toLive(t),
icon: const Icon(Icons.sync_alt, size: 18),
label: Text(tr('lsync 로 이동')),
),
IconButton(
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => center.remove(t.id),
),
]),
]),
),
);
}
}
// ───────── lsync (실시간 동기화) ─────────
class _LiveTab extends StatefulWidget {
final AppController c;
const _LiveTab({required this.c});
@override
State<_LiveTab> createState() => _LiveTabState();
}
class _LiveTabState extends State<_LiveTab> {
AppController get c => widget.c;
@override
void initState() {
super.initState();
// 열 때 맞출 것을 새로 센다
final live = LiveSync.instance;
if (live != null) {
for (final x in c.settings.liveSyncPairs) {
live.refreshPending(x);
}
}
}
Future<void> _replace(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
]);
@override
Widget build(BuildContext context) {
final live = LiveSync.instance;
return ListenableBuilder(
listenable: Listenable.merge([c, ?live]),
builder: (context, _) {
final pairs = c.settings.liveSyncPairs;
return ListView(padding: const EdgeInsets.all(12), children: [
Row(children: [
Expanded(
child: Text(
tr('원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
),
TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))),
]),
const SizedBox(height: 8),
if (pairs.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'),
style: const TextStyle(color: JjColors.textDim))),
),
for (final x in pairs) _liveCard(context, live, x),
]);
},
);
}
Widget _liveCard(BuildContext context, LiveSync? live, LiveSyncPair x) {
final k = LiveSync.keyOf(x);
final pend = live?.pending[k];
final st = live?.status[k];
final active = LiveSync.activeNow(x);
final running = live?.isRunning(x) ?? false;
return Card(
color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(running ? Icons.sync : x.enabled ? (active ? Icons.sync : Icons.bedtime_outlined) : Icons.sync_disabled,
color: x.enabled ? (active ? JjColors.accent : Colors.orangeAccent) : JjColors.textDim),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${x.source}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${x.target}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')),
),
Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))),
]),
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
// 일정: 계속 / 시간 지정 (cron) + 작은 격자
InkWell(
onTap: () async {
final r = await editSchedule(context, x.schedule);
if (r != null) await _replace(x, x.copyWith(schedule: r));
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(mainAxisSize: MainAxisSize.min, children: [
WeekGridPreview(lines: x.schedule, width: 120),
const SizedBox(width: 8),
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : Colors.orangeAccent)),
if (x.schedule.isNotEmpty)
Text(x.schedule.join(' | '), style: const TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
Text(tr('누르면 일정 바꾸기'), style: const TextStyle(fontSize: 10, color: JjColors.textDim)),
]),
]),
),
),
if (st != null)
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
_DiskInfo(path: x.target),
]),
),
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
Padding(
padding: const EdgeInsets.only(left: 32, top: 8),
child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty
? Text(tr('맞출 것 없음 (원본과 대상이 같습니다)'), style: const TextStyle(fontSize: 12, color: Colors.greenAccent))
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
),
const SizedBox(height: 4),
Container(
constraints: const BoxConstraints(maxHeight: 140),
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.all(8),
child: ListView(shrinkWrap: true, children: [
for (final f in pend.take(100))
Text(f,
style: TextStyle(
fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? Colors.redAccent : null)),
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
]),
),
]),
),
const SizedBox(height: 6),
Row(children: [
const Spacer(),
TextButton.icon(
onPressed: live == null ? null : () => live.refreshPending(x),
icon: const Icon(Icons.refresh, size: 18),
label: Text(tr('다시 세기')),
),
FilledButton.icon(
onPressed: live == null || running ? null : () => live.syncNow(x),
icon: const Icon(Icons.sync, size: 18),
label: Text(tr('지금 맞추기')),
),
const SizedBox(width: 6),
TextButton.icon(
onPressed: () async {
await CopyCenter.of(c).fromLiveSync(x);
if (context.mounted) {
DefaultTabController.of(context).animateTo(0);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.'))));
}
},
icon: const Icon(Icons.swap_horiz, size: 18),
label: Text(tr('복사 · rsync 로 이동')),
),
IconButton(
tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => c.updateSettings((y) => y.liveSyncPairs = [
for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
]),
),
]),
]),
),
);
}
}

304
lib/ui/schedule_editor.dart Normal file
View File

@ -0,0 +1,304 @@
import 'package:flutter/material.dart';
import '../core/cron_window.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 요일 이름 (0 = 일요일)
List<String> weekdayNames() => [tr('일'), tr('월'), tr('화'), tr('수'), tr('목'), tr('금'), tr('토')];
/// 일정 요약: "계속" · cron 줄 · 지금 상태 (동작 중 ~ 끝 / 다음 시작)
String scheduleSummary(List<String> lines, [DateTime? now]) {
final t = now ?? DateTime.now();
if (lines.isEmpty) return tr('계속 (앱이 켜져 있는 동안)');
String hm(DateTime d) => '${weekdayNames()[d.weekday % 7]} ${d.hour.toString().padLeft(2, '0')}:00';
final end = scheduleEnd(lines, t);
if (scheduleActive(lines, t)) return trf('동작 중 · {0} 까지', [end == null ? '…' : hm(end)]);
final next = scheduleNextStart(lines, t);
return trf('대기 · 다음 시작 {0}', [next == null ? '-' : hm(next)]);
}
/// 일정 편집: 계속 / 시간 지정 (요일 × 시 격자를 누르거나 끌어서 칠하기, cron 글로도). 취소면 null.
Future<List<String>?> editSchedule(BuildContext context, List<String> initial) =>
showDialog<List<String>>(context: context, builder: (_) => _ScheduleDialog(initial: initial));
class _ScheduleDialog extends StatefulWidget {
final List<String> initial;
const _ScheduleDialog({required this.initial});
@override
State<_ScheduleDialog> createState() => _ScheduleDialogState();
}
class _ScheduleDialogState extends State<_ScheduleDialog> {
late bool _always = widget.initial.isEmpty;
late Set<int> _cells = widget.initial.isEmpty ? {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h} : gridFromCron(widget.initial);
/// 격자로 나타낼 수 없는 줄 (일 · 월을 정한 cron) 은 글로만 둔다
late List<String> _extra = [for (final l in widget.initial) if (!(CronExpr.tryParse(l)?.gridable ?? false)) l];
late final _text = TextEditingController(text: _lines.join('\n'));
String? _error;
List<String> get _lines => [...cronFromGrid(_cells), ..._extra];
void _syncText() => _text.text = _lines.join('\n');
void _set(Set<int> cells) {
setState(() {
_cells = cells;
_error = null;
});
_syncText();
}
void _applyText() {
final lines = _text.text.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
for (final l in lines) {
try {
CronExpr.parse(l);
} on FormatException catch (e) {
setState(() => _error = '${e.message}: $l');
return;
}
}
setState(() {
_cells = gridFromCron(lines);
_extra = [for (final l in lines) if (!CronExpr.parse(l).gridable) l];
_error = null;
});
}
@override
void dispose() {
_text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final lines = _lines;
return AlertDialog(
title: Text(tr('동기화 시간')),
content: SizedBox(
width: 760,
child: SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Wrap(spacing: 8, children: [
ChoiceChip(
label: Text(tr('계속')),
selected: _always,
onSelected: (_) => setState(() => _always = true),
),
ChoiceChip(
label: Text(tr('시간 지정 (cron)')),
selected: !_always,
onSelected: (_) => setState(() => _always = false),
),
]),
const SizedBox(height: 8),
Text(
_always
? tr('앱이 켜져 있는 동안 늘 동기화합니다.')
: tr('칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. '
'칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
if (!_always) ...[
const SizedBox(height: 10),
_WeekGrid(cells: _cells, onChanged: _set),
const SizedBox(height: 8),
Wrap(spacing: 6, runSpacing: 6, children: [
for (final (label, cells) in [
(tr('평일 9~18시'), {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h}),
(tr('밤 0~6시'), {for (var d = 0; d < 7; d++) for (var h = 0; h < 6; h++) d * 24 + h}),
(tr('주말 종일'), {for (final d in [0, 6]) for (var h = 0; h < 24; h++) d * 24 + h}),
(tr('매일 종일'), {for (var i = 0; i < 7 * 24; i++) i}),
(tr('모두 지우기'), <int>{}),
])
ActionChip(label: Text(label), onPressed: () => _set(cells)),
]),
const SizedBox(height: 12),
Text(tr('cron (분 시 일 월 요일) · 한 줄에 하나'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
TextField(
controller: _text,
minLines: 2,
maxLines: 5,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: _error,
helperText: tr('예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위'),
suffixIcon: IconButton(tooltip: tr('글을 격자에 반영'), icon: const Icon(Icons.check), onPressed: _applyText),
),
onSubmitted: (_) => _applyText(),
),
const SizedBox(height: 8),
Text(lines.isEmpty ? tr('칠한 시간이 없어 동기화하지 않습니다.') : scheduleSummary(lines),
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
],
]),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(
onPressed: _error != null ? null : () => Navigator.pop(context, _always ? const <String>[] : lines),
child: Text(tr('저장')),
),
],
);
}
}
/// 요일 (세로 7) × 시 (가로 24) 격자. 누르면 켜기 · 끄기, 끌면 첫 칸의 반대로 칠하기.
class _WeekGrid extends StatefulWidget {
final Set<int> cells;
final ValueChanged<Set<int>> onChanged;
const _WeekGrid({required this.cells, required this.onChanged});
@override
State<_WeekGrid> createState() => _WeekGridState();
}
class _WeekGridState extends State<_WeekGrid> {
static const _label = 34.0, _head = 18.0, _rowH = 26.0;
bool? _paint;
Set<int> _work = {};
int? _cellAt(Offset pos, double cellW) {
final x = pos.dx - _label, y = pos.dy - _head;
if (x < 0 || y < 0) return null;
final h = (x / cellW).floor(), d = (y / _rowH).floor();
if (h < 0 || h > 23 || d < 0 || d > 6) return null;
return d * 24 + h;
}
void _toggleRow(int d) {
final all = [for (var h = 0; h < 24; h++) d * 24 + h];
final on = all.every(widget.cells.contains);
widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all));
}
void _toggleCol(int h) {
final all = [for (var d = 0; d < 7; d++) d * 24 + h];
final on = all.every(widget.cells.contains);
widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all));
}
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
final cellW = (box.maxWidth - _label) / 24;
final names = weekdayNames();
void paintAt(Offset pos) {
final i = _cellAt(pos, cellW);
if (i == null || _paint == null) return;
final next = {..._work};
_paint! ? next.add(i) : next.remove(i);
if (next.length != _work.length) {
_work = next;
widget.onChanged(next);
}
}
return SizedBox(
height: _head + _rowH * 7,
child: Stack(children: [
// 시 숫자 (누르면 그 시간 전체 요일)
for (var h = 0; h < 24; h++)
Positioned(
left: _label + h * cellW,
top: 0,
width: cellW,
height: _head,
child: InkWell(
onTap: () => _toggleCol(h),
child: Center(
child: Text(h % 3 == 0 ? '$h' : '', style: const TextStyle(fontSize: 10, color: JjColors.textDim)),
),
),
),
// 요일 이름 (누르면 그 요일 전체)
for (var d = 0; d < 7; d++)
Positioned(
left: 0,
top: _head + d * _rowH,
width: _label,
height: _rowH,
child: InkWell(
onTap: () => _toggleRow(d),
child: Center(
child: Text(names[d],
style: TextStyle(fontSize: 12, color: d == 0 ? Colors.redAccent : d == 6 ? Colors.lightBlueAccent : null)),
),
),
),
Positioned(
left: _label,
top: _head,
right: 0,
height: _rowH * 7,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (e) {
final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW);
if (i == null) return;
final next = {...widget.cells};
next.contains(i) ? next.remove(i) : next.add(i);
widget.onChanged(next);
},
onPanStart: (e) {
final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW);
_work = {...widget.cells};
_paint = i == null ? null : !widget.cells.contains(i);
paintAt(e.localPosition + const Offset(_label, _head));
},
onPanUpdate: (e) => paintAt(e.localPosition + const Offset(_label, _head)),
onPanEnd: (_) => _paint = null,
child: CustomPaint(painter: _GridPainter(widget.cells, cellW, _rowH)),
),
),
]),
);
});
}
class _GridPainter extends CustomPainter {
final Set<int> cells;
final double cellW, rowH;
_GridPainter(this.cells, this.cellW, this.rowH);
@override
void paint(Canvas canvas, Size size) {
final on = Paint()..color = JjColors.accent;
final off = Paint()..color = JjColors.border.withValues(alpha: 0.45);
for (var d = 0; d < 7; d++) {
for (var h = 0; h < 24; h++) {
final r = Rect.fromLTWH(h * cellW + 1, d * rowH + 1, cellW - 2, rowH - 2);
canvas.drawRRect(RRect.fromRectAndRadius(r, const Radius.circular(3)), cells.contains(d * 24 + h) ? on : off);
}
}
}
@override
bool shouldRepaint(_GridPainter old) => old.cells != cells || old.cellW != cellW;
}
/// 읽기 전용 작은 격자 (모니터링 · 설정의 목록에서 일정 한눈에)
class WeekGridPreview extends StatelessWidget {
final List<String> lines;
final double width;
const WeekGridPreview({super.key, required this.lines, this.width = 168});
@override
Widget build(BuildContext context) {
final cells = lines.isEmpty ? {for (var i = 0; i < 168; i++) i} : gridFromCron(lines);
final cellW = width / 24;
return SizedBox(
width: width,
height: cellW * 7 * 1.2,
child: CustomPaint(painter: _GridPainter(cells, cellW, cellW * 1.2)),
);
}
}

161
test/monitor_test.dart Normal file
View File

@ -0,0 +1,161 @@
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/copy_center.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/cron_window.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p;
void main() {
group('cron 일정', () {
// 2026-10-05 은 월요일
final mon10 = DateTime(2026, 10, 5, 10, 30), mon20 = DateTime(2026, 10, 5, 20), sat10 = DateTime(2026, 10, 10, 10);
test('읽기 · 시간 창 (시 · 요일 · 범위 · 간격 · 이름)', () {
final e = CronExpr.parse('0 9-17 * * 1-5');
expect([e.activeAt(mon10), e.activeAt(mon20), e.activeAt(sat10)], [true, false, false]);
expect(CronExpr.parse('0 */2 * * SAT,SUN').activeAt(sat10), isTrue);
expect(CronExpr.parse('0 * * * 7').activeAt(DateTime(2026, 10, 11, 3)), isTrue); // 7 = 일요일
expect(CronExpr.parse('0 10 5 10 *').activeAt(mon10), isTrue); // 10월 5일 10시
expect(() => CronExpr.parse('0 25 * * *'), throwsFormatException);
expect(() => CronExpr.parse('0 9 * *'), throwsFormatException);
expect(CronExpr.tryParse('x'), isNull);
});
test('계속 · 다음 시작 · 끝나는 때', () {
expect(scheduleActive(const [], sat10), isTrue);
const work = ['0 9-17 * * 1-5'];
expect(scheduleNextStart(work, mon20), DateTime(2026, 10, 6, 9));
expect(scheduleNextStart(work, mon10), isNull);
expect(scheduleEnd(work, mon10), DateTime(2026, 10, 5, 18));
expect(scheduleNextStart(work, sat10), DateTime(2026, 10, 12, 9)); // 다음 월요일
});
test('격자 ↔ cron (같은 시간대 요일끼리 한 줄)', () {
final cells = {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h, 6 * 24 + 22, 6 * 24 + 23};
final lines = cronFromGrid(cells);
expect(lines, ['0 9-17 * * 1-5', '0 22-23 * * 6']);
expect(gridFromCron(lines), cells);
expect(cronFromGrid({for (var i = 0; i < 168; i++) i}), ['0 * * * *']);
expect(gridFromCron(['0 10 5 10 *']), isEmpty); // 일 · 월을 정한 줄은 격자로 못 그림
});
});
group('복사 기억 · lsync', () {
late Directory tmp;
late AppController c;
late String src, dst;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_mon_');
src = p.join(tmp.path, 'src');
dst = p.join(tmp.path, 'dst');
File(p.join(src, 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('a');
File(p.join(src, 'sub', 'b.txt'))
..createSync(recursive: true)
..writeAsStringSync('b');
Directory(dst).createSync();
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
});
tearDown(() => tmp.deleteSync(recursive: true));
test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async {
final center = CopyCenter(c);
final t = await center.remember([src], dst);
await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77));
final again = await center.remember([src], dst);
expect([again.id, again.options, again.bandwidthKBps], [t.id, '-a --custom', 77]);
expect(center.tasks, hasLength(1));
final job = await center.start(center.tasks.single);
await job!.done;
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(File(p.join(dst, 'src', 'sub', 'b.txt')).readAsStringSync(), 'b');
expect(center.tasks.single.lastResult, 'done');
expect(center.tasks.single.lastFiles, 2);
// lsync 로: 원본 → 대상\이름
expect(await center.toLiveSync(center.tasks.single), 1);
expect(center.tasks, isEmpty);
final pair = c.settings.liveSyncPairs.single;
expect([pair.source, pair.target], [src, p.join(dst, 'src')]);
// 다시 복사 목록으로 (안의 것 → 대상)
final back = await center.fromLiveSync(pair);
expect(c.settings.liveSyncPairs, isEmpty);
expect([back.contents, back.dest], [true, p.join(dst, 'src')]);
expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면)
File(p.join(src, 'new.txt')).writeAsStringSync('n');
// (시험 환경에는 rsync 가 없어 현재 방식으로 실행 - rsync 실행은 sync_tools_test)
final j2 = await center.start(back.copyWith(method: 'builtin'));
await j2!.done;
expect(File(p.join(dst, 'src', 'new.txt')).readAsStringSync(), 'n'); // 안의 것 모드: 이름 폴더가 또 생기지 않음
expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse);
});
test('lsync: 맞출 것 자동으로 세기 · 일정 밖이면 맞추지 않음', () async {
final target = p.join(dst, 'mirror');
// 일정: 지금이 아닌 시간 (지금 시 + 2)
final off = (DateTime.now().hour + 2) % 24;
final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt']));
expect(LiveSync.activeNow(pair), isFalse);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c)..start();
await Future<void>.delayed(const Duration(seconds: 4)); // 모아서 3초 뒤
expect(live.pending[LiveSync.keyOf(pair)], hasLength(2));
expect(Directory(target).existsSync(), isFalse); // 일정 밖: 맞추지 않음
// 지금 맞추기는 늘 된다
await live.syncNow(pair);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
File(p.join(target, 'extra.txt')).writeAsStringSync('x');
expect(await LiveSync.diff(pair), ['− extra.txt']);
live.dispose();
});
});
testWidgets('모니터링 화면: 복사 목록 · 옵션 고치기 → 현재 유지 / 반영 후 실행, lsync 탭 · 일정', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_monui_');
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..copyMonitor = true
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c)));
await tester.pump();
expect(find.text('아직 실행 안 함'), findsOneWidget);
expect(find.text('실행'), findsOneWidget);
expect(find.text('lsync 로 이동'), findsOneWidget);
// 속도 제한을 고치면 [저장] → 묻기 → 현재 유지 (실행 안 함)
await tester.enterText(find.widgetWithText(TextField, '속도 제한'), '500');
await tester.pump();
await tester.tap(find.text('저장'));
await tester.pumpAndSettle();
expect(find.text('옵션을 저장했습니다'), findsOneWidget);
expect(find.text('설정 반영 후 실행'), findsOneWidget);
await tester.tap(find.text('현재 유지'));
await tester.pumpAndSettle();
expect(c.settings.copyTasks.single.bandwidthKBps, 500);
expect(CopyCenter.of(c).jobs, isEmpty);
// lsync 탭: 일정 요약 · 맞추기 버튼
await tester.tap(find.text('lsync (실시간 동기화)'));
await tester.pumpAndSettle();
expect(find.textContaining('0 9-17 * * 1-5'), findsOneWidget);
expect(find.text('지금 맞추기'), findsOneWidget);
expect(find.text('복사 · rsync 로 이동'), findsOneWidget);
});
}