diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index a5e2399..a4c770f 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -181,6 +181,13 @@ class MainActivity : FlutterActivity() { } // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) "netKinds" -> result.success(netKinds()) + // X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi). + // VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제 + "activeMetered" -> result.success(try { + (getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered + } catch (e: Exception) { + true + }) "diskSpace" -> result.success( try { val st = android.os.StatFs(call.argument("path") ?: "") diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 293d628..030008f 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -362,6 +362,7 @@ "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept", + "WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "Shows small previews of pictures and videos on WebDAV, SFTP, SMB and FTP in the X-plore style list. Only visible rows, at most 2 at a time", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?", @@ -393,6 +394,7 @@ "Wi-Fi 공유를 켰습니다": "Wi-Fi sharing turned on", "Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "Can't turn on: not connected to Wi-Fi or a wired network", "Wi-Fi 로 공유": "Share over Wi-Fi", + "Wi-Fi 에서만 (기본)": "Wi-Fi only (default)", "Windows 가 쓰는 이름 ({0})": "Name reserved by Windows ({0})", "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", @@ -551,6 +553,7 @@ "{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed", "{0}: 저장하지 않았습니다": "{0}: not saved", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", + "{0}MB (기본)": "{0}MB (default)", "{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "Couldn't convert pages {0}-{1} within {2} s", "{0}개": "{0}", "{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP", @@ -931,6 +934,7 @@ "넓게": "Wide", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)", "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", + "넘으면 오래 보지 않은 것부터 지웁니다": "When full, the least recently viewed are deleted first", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP", "넣지 못했습니다: {0}": "Could not add: {0}", "네덜란드어": "Dutch", @@ -948,6 +952,7 @@ "누르면 내림차순": "Click for descending", "누르면 오름차순": "Click for ascending", "누르면 일정 바꾸기": "Tap to change the schedule", + "늘 (모바일 데이터에서도)": "Always (also on mobile data)", "늘 건너뛰기": "Always skip", "늘 계산": "Always calculate", "늘 덮어쓰기": "Always overwrite", @@ -1380,6 +1385,7 @@ "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "Download failed. Please try again shortly", "받지 못했습니다: {0}": "Could not download: {0}", + "받지 않음": "Off", "받지 않음 (처음 쓸 때 내려받기 {0})": "Not downloaded (downloads on first use, {0})", "밝게": "Brighter", "밝기": "Brightness", @@ -1959,6 +1965,9 @@ "우크라이나어": "Ukrainian", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.", "원격 서버 응답 기다리기": "Wait for remote server response", + "원격 썸네일": "Remote thumbnails", + "원격 썸네일 캐시": "Remote thumbnail cache", + "원격 썸네일 캐시 지우기": "Clear remote thumbnail cache", "원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}", "원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)", @@ -2463,6 +2472,7 @@ "즐겨찾기에 추가": "Add to favorites", "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "지금": "Current", + "지금 {0} · 다시 보면 다시 받습니다": "Now {0} · downloaded again when viewed", "지금 {0}%": "Now {0}%", "지금 것만 취소 (다음 것부터 이어서)": "Cancel only the current one (continue with the next)", "지금 것만 취소: {0}": "Cancelled only the current one: {0}", @@ -2624,6 +2634,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.", "칸 그림": "Treemap", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "Options in the field: {0}\nSaved options: {1}\n\nSave the edited options and run?", + "캐시 지우기": "Clear cache", "컴포넌트": "Components", "컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})", "켜기": "On", @@ -2651,6 +2662,7 @@ "크기: {0} ({1} 바이트)": "Size: {0} ({1} bytes)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Sharpens without changing the size and saves new files. The originals and ZIP stay unchanged. It continues after you close the screen; the task notification shows remaining pages and estimated time.", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", + "크기를 재는 중 …": "Measuring size …", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", "크메르어": "Khmer", "큰 파일": "Large files", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index bbeed8c..b4ecf72 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -362,6 +362,7 @@ "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します", + "WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "WebDAV・SFTP・SMB・FTP の画像・動画を X-plore 形式の一覧で小さな画像として表示します。表示中の行だけ、同時に 2 件まで取得します", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", @@ -393,6 +394,7 @@ "Wi-Fi 공유를 켰습니다": "Wi-Fi 共有をオンにしました", "Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "Wi-Fi や有線ネットワークに接続されていないためオンにできません", "Wi-Fi 로 공유": "Wi-Fi で共有", + "Wi-Fi 에서만 (기본)": "Wi-Fi のみ (既定)", "Windows 가 쓰는 이름 ({0})": "Windows が使う名前 ({0})", "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", @@ -551,6 +553,7 @@ "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした", "{0}: 저장하지 않았습니다": "{0}: 保存しませんでした", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", + "{0}MB (기본)": "{0}MB (既定)", "{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "{0}~{1} ページを {2} 秒以内に変換できませんでした", "{0}개": "{0}個", "{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる", @@ -931,6 +934,7 @@ "넓게": "広く", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)", "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", + "넘으면 오래 보지 않은 것부터 지웁니다": "上限を超えると、長く見ていないものから削除します", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP", "넣지 못했습니다: {0}": "追加できませんでした: {0}", "네덜란드어": "オランダ語", @@ -948,6 +952,7 @@ "누르면 내림차순": "クリックで降順", "누르면 오름차순": "クリックで昇順", "누르면 일정 바꾸기": "タップで予定を変更", + "늘 (모바일 데이터에서도)": "常に (モバイルデータでも)", "늘 건너뛰기": "常にスキップ", "늘 계산": "常に計算", "늘 덮어쓰기": "常に上書き", @@ -1380,6 +1385,7 @@ "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "ダウンロードできませんでした。しばらくしてからもう一度お試しください", "받지 못했습니다: {0}": "ダウンロードできませんでした: {0}", + "받지 않음": "取得しない", "받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})", "밝게": "明るく", "밝기": "明るさ", @@ -1959,6 +1965,9 @@ "우크라이나어": "ウクライナ語", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。", "원격 서버 응답 기다리기": "リモートサーバーの応答待ち", + "원격 썸네일": "リモートのサムネイル", + "원격 썸네일 캐시": "リモートのサムネイルのキャッシュ", + "원격 썸네일 캐시 지우기": "リモートのサムネイルのキャッシュを削除", "원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}", "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", @@ -2463,6 +2472,7 @@ "즐겨찾기에 추가": "お気に入りに追加", "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "지금": "現在", + "지금 {0} · 다시 보면 다시 받습니다": "現在 {0} ・再び表示すると再取得します", "지금 {0}%": "現在 {0}%", "지금 것만 취소 (다음 것부터 이어서)": "今のものだけ取り消し (次から続ける)", "지금 것만 취소: {0}": "今のものだけ取り消し: {0}", @@ -2624,6 +2634,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", "칸 그림": "ツリーマップ", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "欄に表示中のオプション: {0}\n保存済みのオプション: {1}\n\n編集したオプションを保存して実行しますか?", + "캐시 지우기": "キャッシュを削除", "컴포넌트": "コンポーネント", "컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})", "켜기": "オン", @@ -2651,6 +2662,7 @@ "크기: {0} ({1} 바이트)": "サイズ: {0} ({1} バイト)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "サイズはそのままでくっきりさせ、新しいファイルに保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残り枚数と予想時間が表示されます。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", + "크기를 재는 중 …": "サイズを計測中 …", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", "크메르어": "クメール語", "큰 파일": "大きいファイル", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 43a2cee..38d165a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -362,6 +362,7 @@ "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名", + "WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "在 X-plore 样式列表中将 WebDAV、SFTP、SMB、FTP 上的图片和视频显示为小图。仅可见行,最多同时 2 个", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", @@ -393,6 +394,7 @@ "Wi-Fi 공유를 켰습니다": "已开启 Wi-Fi 共享", "Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "未连接到 Wi-Fi 或有线网络,无法开启", "Wi-Fi 로 공유": "通过 Wi-Fi 共享", + "Wi-Fi 에서만 (기본)": "仅 Wi-Fi (默认)", "Windows 가 쓰는 이름 ({0})": "Windows 保留名称 ({0})", "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", @@ -551,6 +553,7 @@ "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动", "{0}: 저장하지 않았습니다": "{0}:未保存", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", + "{0}MB (기본)": "{0}MB (默认)", "{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "无法在 {2} 秒内转换第 {0}~{1} 页", "{0}개": "{0} 个", "{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP", @@ -931,6 +934,7 @@ "넓게": "宽松", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)", "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", + "넘으면 오래 보지 않은 것부터 지웁니다": "超出时先删除最久未查看的", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP", "넣지 못했습니다: {0}": "无法加入: {0}", "네덜란드어": "荷兰语", @@ -948,6 +952,7 @@ "누르면 내림차순": "点击改为降序", "누르면 오름차순": "点击改为升序", "누르면 일정 바꾸기": "点按以更改计划", + "늘 (모바일 데이터에서도)": "始终 (包括移动数据)", "늘 건너뛰기": "总是跳过", "늘 계산": "始终计算", "늘 덮어쓰기": "总是覆盖", @@ -1380,6 +1385,7 @@ "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "下载失败。请稍后重试", "받지 못했습니다: {0}": "无法下载: {0}", + "받지 않음": "不获取", "받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})", "밝게": "调亮", "밝기": "亮度", @@ -1959,6 +1965,9 @@ "우크라이나어": "乌克兰语", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。", "원격 서버 응답 기다리기": "等待远程服务器响应", + "원격 썸네일": "远程缩略图", + "원격 썸네일 캐시": "远程缩略图缓存", + "원격 썸네일 캐시 지우기": "清除远程缩略图缓存", "원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}", "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", @@ -2463,6 +2472,7 @@ "즐겨찾기에 추가": "添加到收藏", "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "지금": "当前", + "지금 {0} · 다시 보면 다시 받습니다": "当前 {0} · 再次查看时会重新获取", "지금 {0}%": "当前 {0}%", "지금 것만 취소 (다음 것부터 이어서)": "仅取消当前项(从下一个继续)", "지금 것만 취소: {0}": "仅取消当前项:{0}", @@ -2624,6 +2634,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", "칸 그림": "矩形树图", "칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?": "输入框中的选项: {0}\n已保存的选项: {1}\n\n保存修改的选项并执行吗?", + "캐시 지우기": "清除缓存", "컴포넌트": "组件", "컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})", "켜기": "开启", @@ -2651,6 +2662,7 @@ "크기: {0} ({1} 바이트)": "大小: {0} ({1} 字节)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "保持尺寸只做锐化并保存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余张数和预计时间。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", + "크기를 재는 중 …": "正在计算大小 …", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", "크메르어": "高棉语", "큰 파일": "大文件", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 07ef0f9..dda7dfa 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -473,6 +473,14 @@ class AppSettings { /// 처음 끔 (지금 정렬 그대로). 켜도 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억한다 ([explorerFolderSorts]) bool explorerSortByKind = false; + /// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 썸네일 - 'wifi' 데이터 요금제가 아닌 망에서만 (처음 값) · + /// 'always' 늘 · 'off' 받지 않음. X-plore 모양의 목록에서 보이는 줄의 그림 · 동영상만, 동시에 2개까지 + String explorerRemoteThumbs = 'wifi'; + + /// 원격 썸네일 캐시 상한 (MB, 처음 200) - 넘으면 오래 쓰지 않은 것부터 지운다 + int explorerRemoteThumbCacheMb = 200; + static const remoteThumbCacheChoices = [50, 100, 200, 500, 1000]; + /// 84: 폴더 → "기준|desc" (예: "size|1"). 오래된 것부터 지워 300개까지 Map explorerFolderSorts = {}; @@ -871,6 +879,8 @@ class AppSettings { 'wifiShareFolder': wifiShareFolder, 'explorerSortPerFolder': explorerSortPerFolder, 'explorerSortByKind': explorerSortByKind, + 'explorerRemoteThumbs': explorerRemoteThumbs, + 'explorerRemoteThumbCacheMb': explorerRemoteThumbCacheMb, 'explorerFolderSorts': explorerFolderSorts, 'explorerShowHidden': explorerShowHidden, 'explorerHiddenItems': explorerHiddenItems, @@ -1114,6 +1124,10 @@ class AppSettings { ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerSortByKind = j['explorerSortByKind'] == true + ..explorerRemoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi' + ..explorerRemoteThumbCacheMb = j['explorerRemoteThumbCacheMb'] is num + ? (j['explorerRemoteThumbCacheMb'] as num).round().clamp(10, 10000) + : 200 ..explorerProgressHidden = j['explorerProgressHidden'] == true ..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6 ..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30 diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart index 3fd1fe5..d4c4604 100644 --- a/lib/core/lan_scan.dart +++ b/lib/core/lan_scan.dart @@ -40,6 +40,19 @@ Future?> _androidNetKinds() async { } } +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제 Wi-Fi). Android 만 묻고, +/// 그 밖 (PC) 은 false. 물을 수 없으면 true (받지 않는 쪽으로). 시험에서 바꾼다 +Future Function() activeMeteredOf = _androidMetered; + +Future _androidMetered() async { + if (!Platform.isAndroid) return false; + try { + return await const MethodChannel('jj_mkvmaker/android').invokeMethod('activeMetered') ?? true; + } catch (_) { + return true; + } +} + /// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로 bool isLanAddress(String ip, String interfaceName, Map? kinds) { final k = kinds?[ip]; diff --git a/lib/core/remote_stream.dart b/lib/core/remote_stream.dart index 59180ae..d61c610 100644 --- a/lib/core/remote_stream.dart +++ b/lib/core/remote_stream.dart @@ -9,6 +9,7 @@ import 'ftp.dart' show FtpRemote; import 'sftp.dart' show SftpRemote; import 'smb.dart' show SmbRemote; import 'vfs.dart'; +import 'webdav.dart' show DavClient; /// sftp_next (관리자 지시 10/17): SFTP · SMB 동영상을 받지 않고 스트리밍하는 이 기기 안 (127.0.0.1) 의 작은 HTTP 다리. /// 플레이어 (mpv) 가 Range 로 부분을 달라고 하면 SFTP · SMB 에서 그 부분만 읽어 준다 - 건너뛰기가 되고, @@ -64,6 +65,8 @@ class RemoteStreamBridge { if (client is SmbRemote) return client.openReadFrom(d.rel, start: start, length: length); // l FTP: REST 로 그 자리부터 (15 가 알린 것 - 합친 뒤 넣음) if (client is FtpRemote) return client.openReadFrom(d.rel, offset: start, length: length); + // X-plore 비교 5-1 ② (10/21): WebDAV 도 Range 로 (원격 썸네일이 비밀번호를 FFmpeg 명령 줄에 넣지 않고 이 다리를 쓴다) + if (client is DavClient) return client.openReadFrom(d.rel, offset: start, length: length); // 그 밖 (WebDAV 는 다리를 쓰지 않지만 혹시 오면): 처음부터 읽으며 건너뛴다 return _skip(await client.openRead(d.rel), start, length); } diff --git a/lib/core/remote_thumbs.dart b/lib/core/remote_thumbs.dart new file mode 100644 index 0000000..ed68c57 --- /dev/null +++ b/lib/core/remote_thumbs.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 의 그림 · 동영상 썸네일. +/// - 받는 때: 설정 'wifi' (처음 값) 면 데이터 요금제가 아닌 망에서만, 'always' 면 늘, 'off' 면 받지 않음 +/// - 서버에 부담이 가지 않게 동시에 [parallel] 개까지 (처음 2). 줄 세운 것 중 화면에서 사라진 줄은 시작하지 않는다 +/// - 만든 썸네일은 [dir] 에 두고, 모두 [maxBytes] 를 넘으면 오래 쓰지 않은 것부터 지운다. [clear] 로 모두 지움 +/// - 만들기 ([make]) 는 부르는 쪽이 준다 (앱: FFmpeg 가 이 기기 안 스트리밍 다리에서 앞 · 뒤 일부만 읽음) +class RemoteThumbs { + final Directory dir; + + /// 캐시 상한 (바이트) + int Function() maxBytes; + + /// 동시에 받는 수 + final int parallel; + + /// 지금 받아도 되는지 (설정 · 망) + final Future Function() allowed; + + /// [path] 의 썸네일을 [out] (jpg) 에 만든다 - 만들었으면 true + final Future Function(String path, String out) make; + + RemoteThumbs({required this.dir, required this.maxBytes, required this.allowed, required this.make, this.parallel = 2}); + + final _done = {}; + final _waiting = Queue<_Job>(); + final _jobs = {}; + int _running = 0; + + /// 시험: 지금까지 [make] 를 부른 경로 · 동시에 돈 가장 큰 수 + final made = []; + int peak = 0; + + static String keyOf(String path, int size, DateTime modified) => '$path|$size|${modified.millisecondsSinceEpoch}'; + + /// 이미 만든 것 (없으면 null - 만들기는 [request]) + File? cached(String key) => _done[key]; + + /// 썸네일을 달라고 줄 세운다. [wanted] 가 시작할 때 false 면 (화면에서 사라진 줄) 건너뛴다. + /// 끝나면 [onDone] (못 만들었으면 null). 받지 않는 망이면 줄 세우지 않고 null + Future request(String path, String key, {required bool Function() wanted}) async { + if (_done.containsKey(key)) return _done[key]; + final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); + if (await out.exists() && await out.length() > 0) { + unawaited(out.setLastModified(DateTime.now()).catchError((_) {})); + return _done[key] = out; + } + final old = _jobs[key]; + if (old != null) { + old.wanted.add(wanted); + return old.result.future; + } + if (!await allowed()) return null; + final job = _Job(path, key, out)..wanted.add(wanted); + _jobs[key] = job; + _waiting.add(job); + _pump(); + return job.result.future; + } + + void _pump() { + while (_running < parallel && _waiting.isNotEmpty) { + final job = _waiting.removeFirst(); + // 화면에서 사라진 줄이면 시작하지 않는다 (다시 보이면 다시 달라고 한다) + if (!job.wanted.any((w) => w())) { + _jobs.remove(job.key); + job.result.complete(null); + continue; + } + _running++; + if (_running > peak) peak = _running; + unawaited(_run(job)); + } + } + + Future _run(_Job job) async { + File? got; + try { + if (await allowed()) { + await dir.create(recursive: true); + made.add(job.path); + if (await make(job.path, job.out.path) && await job.out.exists() && await job.out.length() > 0) { + got = job.out; + _done[job.key] = got; + await _trim(); + } + } + } catch (_) { + got = null; + } finally { + _running--; + _jobs.remove(job.key); + job.result.complete(got); + _pump(); + } + } + + /// 캐시의 크기 (바이트) + Future size() async { + var n = 0; + if (!await dir.exists()) return 0; + await for (final f in dir.list()) { + if (f is File) n += await f.length().catchError((_) => 0); + } + return n; + } + + /// 상한을 넘으면 오래 쓰지 않은 것 (바뀐 때가 옛것) 부터 지워 상한의 90% 아래로 + Future _trim() async { + final files = <(File, int, DateTime)>[]; + var total = 0; + await for (final f in dir.list()) { + if (f is! File) continue; + final st = await f.stat(); + files.add((f, st.size, st.modified)); + total += st.size; + } + final cap = maxBytes(); + if (total <= cap) return; + files.sort((a, b) => a.$3.compareTo(b.$3)); + for (final (f, size, _) in files) { + if (total <= cap * 0.9) break; + try { + await f.delete(); + total -= size; + _done.removeWhere((_, v) => v?.path == f.path); + } catch (_) {} + } + } + + /// 모두 지운다 (환경 설정 [캐시 지우기]) + Future clear() async { + _done.clear(); + if (await dir.exists()) { + await for (final f in dir.list()) { + try { + await f.delete(recursive: true); + } catch (_) {} + } + } + } +} + +class _Job { + final String path, key; + final File out; + final wanted = []; + final result = Completer(); + _Job(this.path, this.key, this.out); +} diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 48f4ec7..8e7aafc 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -87,6 +87,29 @@ class DavException implements Exception { } /// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic). +/// [s] 의 앞 [start] 바이트를 건너뛰고 [length] 바이트 (null 이면 끝까지) +Stream> skipBytes(Stream> s, int start, int? length) async* { + var skip = start; + var left = length; + await for (var chunk in s) { + if (skip > 0) { + if (chunk.length <= skip) { + skip -= chunk.length; + continue; + } + chunk = chunk.sublist(skip); + skip = 0; + } + if (left != null) { + if (left <= 0) break; + if (chunk.length > left) chunk = chunk.sublist(0, left); + left -= chunk.length; + } + yield chunk; + if (left != null && left <= 0) break; + } +} + class DavClient implements RemoteClient { final DavServer server; late final HttpClient _http = HttpClient() @@ -292,6 +315,21 @@ class DavClient implements RemoteClient { return idleRead(res); } + /// X-plore 비교 5-1 ② (관리자 지시 10/21): [offset] 부터 [length] 바이트 (null 이면 끝까지) - 원격 썸네일이 동영상의 앞 · 뒤 일부만 읽게 + /// (스트리밍 다리의 Range). 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛴다 + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { + if (offset == 0 && length == null) return openRead(rel); + final req = await _open('GET', uriOf(rel)); + req.headers.set(HttpHeaders.rangeHeader, 'bytes=$offset-${length == null ? '' : offset + length - 1}'); + final res = await _send(req); + if (res.statusCode == 206) return idleRead(res); + if (res.statusCode != 200) { + await res.drain(); + _fail(tr('받기'), res); + } + return skipBytes(idleRead(res), offset, length); + } + /// 올리기 ([length] 를 알면 Content-Length 로) @override Future write(String rel, Stream> data, {int? length}) async { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 67348c9..1a6aa79 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -57,6 +57,7 @@ import '../core/hwp_source.dart'; import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; +import 'remote_thumb_view.dart'; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; import '../core/file_attributes.dart' show readFileAttributes; @@ -5783,7 +5784,13 @@ class _ExplorerPageState extends State { final look = _look; if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); - // X-plore: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만) + // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘 + if (look.rich && isDav(e.path) && c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) { + final (icon, color) = fileIcon(look, e, open: open); + return RemoteThumb( + key: ValueKey('rthumb:${e.path}'), c: c, entry: e, fallback: Icon(icon, size: 28, color: color)); + } + // X-plore: 동영상 썸네일 · 그림 미리 보기 if (look.rich && !e.isDir && !isDav(e.path)) { if (isVideoFile(e.path)) return _Thumb(c: c, entry: e); const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}; diff --git a/lib/ui/remote_thumb_view.dart b/lib/ui/remote_thumb_view.dart new file mode 100644 index 0000000..dd158d6 --- /dev/null +++ b/lib/ui/remote_thumb_view.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../core/file_ops.dart' show FileEntry; +import '../core/lan_scan.dart' show activeMeteredOf; +import '../core/playlist.dart' show isVideoFile; +import '../core/remote_stream.dart'; +import '../core/remote_thumbs.dart'; +import 'theme.dart'; + +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나 +Future? _hub; + +Future remoteThumbsOf(AppController c) => _hub ??= () async { + final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_rthumbs')); + return RemoteThumbs( + dir: dir, + maxBytes: () => c.settings.explorerRemoteThumbCacheMb * 1024 * 1024, + allowed: () => remoteThumbsAllowedNow(c), + make: (path, out) => _make(c, path, out), + ); + }(); + +/// 시험: 다음에 새로 만든다 +void resetRemoteThumbsForTest() => _hub = null; + +/// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게) +const remoteThumbImageMax = 30 * 1024 * 1024; + +const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}; + +/// 원격 썸네일을 받을 종류인지 (그림 - [remoteThumbImageMax] 아래 · 동영상) +bool remoteThumbKind(FileEntry e) => !e.isDir && (isVideoFile(e.path) || (_images.contains(e.ext) && e.size <= remoteThumbImageMax)); + +DateTime? _meteredAt; +bool _metered = true; + +/// 지금 받아도 되는지: 'off' 아니오 · 'always' 예 · 'wifi' (처음 값) 는 데이터 요금제가 아닌 망에서만 (10초 동안 기억) +Future remoteThumbsAllowedNow(AppController c) async { + switch (c.settings.explorerRemoteThumbs) { + case 'off': + return false; + case 'always': + return true; + } + final now = DateTime.now(); + if (_meteredAt == null || now.difference(_meteredAt!) > const Duration(seconds: 10)) { + _metered = await activeMeteredOf(); + _meteredAt = now; + } + return !_metered; +} + +/// 시험: 망 판단 기억을 지운다 +void resetRemoteThumbNetForTest() => _meteredAt = null; + +/// FFmpeg 가 이 기기 안 스트리밍 다리 (127.0.0.1, Range) 에서 필요한 부분만 읽어 가로 160 jpg 로. 비밀번호는 명령 줄에 넣지 않는다. +/// MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀) +Future _make(AppController c, String path, String out) async { + if (c.busy) return false; + final url = (await RemoteStreamBridge.get()).url(path); + for (final at in isVideoFile(path) ? ['5', '0'] : [null]) { + await c.services.mediaTool.runFfmpeg([ + '-hide_banner', '-loglevel', 'error', if (at != null) ...['-ss', at], '-i', url, // + '-frames:v', '1', '-vf', 'scale=160:-2', '-y', out, + ]); + if (await File(out).exists() && await File(out).length() > 0) return true; + } + return false; +} + +/// 목록 줄의 원격 썸네일 (보이는 동안만 달라고 한다 - 화면에서 사라지면 줄 세운 것은 시작하지 않음) +class RemoteThumb extends StatefulWidget { + final AppController c; + final FileEntry entry; + final Widget fallback; + const RemoteThumb({super.key, required this.c, required this.entry, required this.fallback}); + + @override + State createState() => _RemoteThumbState(); +} + +class _RemoteThumbState extends State { + File? _file; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final e = widget.entry; + final key = RemoteThumbs.keyOf(e.path, e.size, e.modified); + final hub = await remoteThumbsOf(widget.c); + final had = hub.cached(key); + if (had != null) { + if (mounted) setState(() => _file = had); + return; + } + final f = await hub.request(e.path, key, wanted: () => mounted); + if (mounted && f != null) setState(() => _file = f); + } + + @override + Widget build(BuildContext context) { + final f = _file; + if (f == null) return widget.fallback; + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Stack(alignment: Alignment.center, children: [ + Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => widget.fallback), + if (isVideoFile(widget.entry.path)) const Icon(Icons.play_arrow, size: 18, color: Colors.white70), + // 원격에서 온 것 표시 (작게) + const Positioned(right: 1, bottom: 1, child: Icon(Icons.cloud_outlined, size: 10, color: JjColors.textDim)), + ]), + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 97d608e..f9584a7 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -9,6 +10,7 @@ import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/i18n_controller.dart' show I18nController; import '../core/download_detect.dart'; +import '../core/file_ops.dart' show formatSize; import '../core/playlist.dart'; import '../core/subtitle_detector.dart'; import '../services/app_shell.dart'; @@ -37,6 +39,7 @@ import 'setting_tile.dart'; import 'component_settings.dart'; import 'browser_page.dart' show deleteExportedCookies; import 'security_settings.dart'; +import 'remote_thumb_view.dart' show remoteThumbsOf; import 'network_settings.dart'; import 'ai_settings.dart'; import 'ai_subtitle_models.dart'; @@ -82,6 +85,45 @@ class CountSelector extends StatelessWidget { } /// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리) +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 캐시의 크기 · [캐시 지우기] (다시 보면 다시 받음) +class _RemoteThumbCacheTile extends StatefulWidget { + final AppController c; + const _RemoteThumbCacheTile({required this.c}); + + @override + State<_RemoteThumbCacheTile> createState() => _RemoteThumbCacheTileState(); +} + +class _RemoteThumbCacheTileState extends State<_RemoteThumbCacheTile> { + int? _bytes; + + @override + void initState() { + super.initState(); + unawaited(_measure()); + } + + Future _measure() async { + final n = await (await remoteThumbsOf(widget.c)).size(); + if (mounted) setState(() => _bytes = n); + } + + @override + Widget build(BuildContext context) => ListTile( + title: Text(tr('원격 썸네일 캐시 지우기')), + subtitle: Text(_bytes == null ? tr('크기를 재는 중 …') : trf('지금 {0} · 다시 보면 다시 받습니다', [formatSize(_bytes!)])), + trailing: TextButton( + onPressed: _bytes == null || _bytes == 0 + ? null + : () async { + await (await remoteThumbsOf(widget.c)).clear(); + await _measure(); + }, + child: Text(tr('캐시 지우기')), + ), + ); +} + class _CountInputDialog extends StatefulWidget { final int initial; const _CountInputDialog({required this.initial}); @@ -804,6 +846,33 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerSortByKind = v), ), ), + // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시 + SettingTile( + title: Text(tr('원격 썸네일')), + subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')), + trailing: DropdownButton( + value: s.explorerRemoteThumbs, + items: [ + DropdownMenuItem(value: 'off', child: Text(tr('받지 않음'))), + DropdownMenuItem(value: 'wifi', child: Text(tr('Wi-Fi 에서만 (기본)'))), + DropdownMenuItem(value: 'always', child: Text(tr('늘 (모바일 데이터에서도)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbs = v!), + ), + ), + SettingTile( + title: Text(tr('원격 썸네일 캐시')), + subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')), + trailing: DropdownButton( + value: s.explorerRemoteThumbCacheMb, + items: [ + for (final v in {...AppSettings.remoteThumbCacheChoices, s.explorerRemoteThumbCacheMb}.toList()..sort()) + DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbCacheMb = v!), + ), + ), + _RemoteThumbCacheTile(c: c), // 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) SettingTile( title: Text(tr('폴더 크기')), diff --git a/test/remote_thumbs_test.dart b/test/remote_thumbs_test.dart new file mode 100644 index 0000000..356fa9b --- /dev/null +++ b/test/remote_thumbs_test.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +import 'package:jj_mkvmaker/core/lan_scan.dart' show activeMeteredOf; +import 'package:jj_mkvmaker/core/remote_stream.dart'; +import 'package:jj_mkvmaker/core/remote_thumbs.dart'; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/core/webdav.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/remote_thumb_view.dart'; +import 'package:path/path.dart' as p; + +import 'support/dav_server.dart'; + +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 - 처음 값 Wi-Fi 에서만 · 동시 2개 · 보이는 줄만 · 캐시 상한 · [캐시 지우기] +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_rthumb_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + RemoteThumbs hub({ + int maxBytes = 1 << 30, + bool allowed = true, + Future Function(String path, String out)? make, + }) => + RemoteThumbs( + dir: Directory(p.join(tmp.path, 'cache')), + maxBytes: () => maxBytes, + allowed: () async => allowed, + make: make ?? + (path, out) async { + await File(out).writeAsBytes(List.filled(100, 1)); + return true; + }, + ); + + test('설정: 처음 값 Wi-Fi 에서만 · 캐시 200MB, 틀린 값은 처음 값으로', () { + final s = AppSettings(); + expect((s.explorerRemoteThumbs, s.explorerRemoteThumbCacheMb), ('wifi', 200)); + expect(AppSettings.fromJson({'explorerRemoteThumbs': 'x', 'explorerRemoteThumbCacheMb': 'y'}).explorerRemoteThumbs, 'wifi'); + final back = AppSettings.fromJson((AppSettings() + ..explorerRemoteThumbs = 'off' + ..explorerRemoteThumbCacheMb = 500) + .toJson()); + expect((back.explorerRemoteThumbs, back.explorerRemoteThumbCacheMb), ('off', 500)); + }); + + /// 파일 읽기 · 쓰기 (실제 IO) 를 기다린다 - [ok] 가 될 때까지 (5초까지) + Future until(bool Function() ok) async { + for (var i = 0; i < 250 && !ok(); i++) { + await Future.delayed(const Duration(milliseconds: 20)); + } + } + + test('동시에 2개까지 · 화면에서 사라진 줄은 시작하지 않음 · 같은 것은 한 번만', () async { + final gates = >{}; + final h = hub(make: (path, out) async { + await (gates[path] = Completer()).future; + await File(out).writeAsBytes([1, 2, 3]); + return true; + }); + var gone = false; + final a = h.request('dav://n/a.jpg', 'a', wanted: () => true); + final a2 = h.request('dav://n/a.jpg', 'a', wanted: () => true); + final b = h.request('dav://n/b.jpg', 'b', wanted: () => true); + final cc = h.request('dav://n/c.jpg', 'c', wanted: () => !gone); + final d = h.request('dav://n/d.jpg', 'd', wanted: () => true); + await until(() => h.made.length >= 2); + await Future.delayed(const Duration(milliseconds: 100)); + expect(h.made.toSet(), {'dav://n/a.jpg', 'dav://n/b.jpg'}, reason: '2개만 시작'); + gone = true; // c 줄이 화면에서 사라짐 + gates['dav://n/a.jpg']!.complete(); + await until(() => h.made.length >= 3); + expect(h.made.toSet(), {'dav://n/a.jpg', 'dav://n/b.jpg', 'dav://n/d.jpg'}, reason: 'c 는 건너뛰고 d'); + gates['dav://n/b.jpg']!.complete(); + gates['dav://n/d.jpg']!.complete(); + expect(await a, isNotNull); + expect(await a2, isNotNull); + expect((await a)!.path, (await a2)!.path, reason: '같은 것은 한 번만 받음'); + expect(await b, isNotNull); + expect(await cc, isNull); + expect(await d, isNotNull); + expect(h.peak, 2); + // 다시 보이면 다시 달라고 해서 받는다 + final again = h.request('dav://n/c.jpg', 'c', wanted: () => true); + await until(() => gates.containsKey('dav://n/c.jpg')); + gates['dav://n/c.jpg']!.complete(); + expect(await again, isNotNull); + }); + + test('받지 않는 망 (allowed false) 이면 만들지 않음 · 디스크에 있는 것은 다시 씀', () async { + final off = hub(allowed: false); + expect(await off.request('dav://n/a.jpg', 'a', wanted: () => true), isNull); + expect(off.made, isEmpty); + final on = hub(); + final f = await on.request('dav://n/a.jpg', 'a', wanted: () => true); + expect(f, isNotNull); + // 앱을 다시 켠 것처럼 새 hub - 디스크의 것을 쓰고 받지 않는다 (망이 막혀 있어도) + final again = hub(allowed: false); + expect((await again.request('dav://n/a.jpg', 'a', wanted: () => true))?.path, f!.path); + expect(again.made, isEmpty); + }); + + test('캐시 상한을 넘으면 오래 쓰지 않은 것부터 지움 · [캐시 지우기]', () async { + final h = hub(maxBytes: 250); // 100 바이트짜리 3개면 넘음 + for (final (i, n) in ['a', 'b', 'c'].indexed) { + final f = await h.request('dav://n/$n.jpg', n, wanted: () => true); + await f!.setLastModified(DateTime(2026, 1, 1 + i)); + } + final d = await h.request('dav://n/d.jpg', 'd', wanted: () => true); + expect(d, isNotNull); + expect(await h.size(), lessThanOrEqualTo(250)); + expect(h.cached('a'), isNull, reason: '가장 옛것 (a) 이 지워짐'); + expect(h.cached('d'), isNotNull); + await h.clear(); + expect(await h.size(), 0); + expect(h.cached('d'), isNull); + }); + + group('받아도 되는 망 (remoteThumbsAllowedNow)', () { + final was = activeMeteredOf; + tearDown(() { + activeMeteredOf = was; + resetRemoteThumbNetForTest(); + }); + AppController ctl() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + test('Wi-Fi 에서만 (처음 값): 데이터 요금제 망이면 아니오 · 아니면 예 · 끔 · 늘', () async { + final c = ctl(); + activeMeteredOf = () async => true; + resetRemoteThumbNetForTest(); + expect(await remoteThumbsAllowedNow(c), isFalse, reason: '모바일 데이터 (VPN 이어도 그 아래가 모바일 데이터)'); + activeMeteredOf = () async => false; + resetRemoteThumbNetForTest(); + expect(await remoteThumbsAllowedNow(c), isTrue, reason: 'Wi-Fi (집 Wi-Fi 위의 Tailscale 포함)'); + c.settings.explorerRemoteThumbs = 'off'; + expect(await remoteThumbsAllowedNow(c), isFalse); + c.settings.explorerRemoteThumbs = 'always'; + activeMeteredOf = () async => true; + resetRemoteThumbNetForTest(); + expect(await remoteThumbsAllowedNow(c), isTrue); + }); + }); + + test('받을 종류: 그림 (30MB 아래) · 동영상만', () { + FileEntry f(String n, int size) => FileEntry('dav://n/$n', isDir: false, size: size, modified: DateTime(2026)); + expect(remoteThumbKind(f('a.jpg', 1000)), isTrue); + expect(remoteThumbKind(f('a.jpg', 40 * 1024 * 1024)), isFalse); + expect(remoteThumbKind(f('a.mp4', 4 << 30)), isTrue, reason: '동영상은 일부만 읽음'); + expect(remoteThumbKind(f('a.txt', 10)), isFalse); + }); + + group('WebDAV Range (스트리밍 다리 · 썸네일이 일부만 읽게)', () { + late TestDavServer server; + setUp(() async { + final root = Directory(p.join(tmp.path, 'remote'))..createSync(); + File(p.join(root.path, 'v.bin')).writeAsBytesSync(List.generate(1000, (i) => i % 256)); + server = TestDavServer(root); + await server.start(); + DavRegistry.configure([DavServer(id: 'nas', name: 'x', url: server.url, user: 'user', password: 'pass')]); + }); + tearDown(() async { + DavRegistry.configure([]); + await RemoteStreamBridge.close(); + await server.stop(); + }); + + Future> read(int start, int? length) async => + (await (await RemoteStreamBridge.openRange('dav://nas/v.bin', start, length)).toList()).expand((x) => x).toList(); + + test('Range 를 아는 서버는 그 부분만 받음 (206) · 모르는 서버는 받으며 건너뜀 - 결과는 같음', () async { + final want = [for (var i = 900; i < 950; i++) i % 256]; + server.ranges = true; + expect(await read(900, 50), want); + expect(server.rangeHeaders.last, 'bytes=900-949'); + expect(await read(990, null), [for (var i = 990; i < 1000; i++) i % 256]); + expect(server.rangeHeaders.last, 'bytes=990-'); + server.ranges = false; + expect(await read(900, 50), want, reason: 'Range 를 모르면 (200) 처음부터 받으며 건너뜀'); + }); + + test('FFmpeg 가 다리에서 원격 동영상 썸네일을 만든다 (비밀번호는 명령 줄에 없음)', () async { + final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); + final mp4 = p.join(tmp.path, 'remote', 'clip.mp4'); + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-t', '7', '-pix_fmt', 'yuv420p', mp4]); + server.ranges = true; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + c.settings.explorerRemoteThumbs = 'always'; + resetRemoteThumbsForTest(); + addTearDown(resetRemoteThumbsForTest); + final h = await remoteThumbsOf(c); + final st = File(mp4).statSync(); + final f = await h.request('dav://nas/clip.mp4', RemoteThumbs.keyOf('dav://nas/clip.mp4', st.size, st.modified), wanted: () => true); + expect(f, isNotNull); + expect(f!.lengthSync(), greaterThan(100)); + expect(server.rangeHeaders, isNotEmpty, reason: '다리를 거쳐 Range 로 읽음'); + await h.clear(); + }); + }); + + test('skipBytes: 조각 경계를 넘어 건너뛰고 자름', () async { + final s = Stream.fromIterable([ + [0, 1, 2], + [3, 4], + [5, 6, 7, 8], + ]); + expect((await skipBytes(s, 2, 4).toList()).expand((x) => x).toList(), [2, 3, 4, 5]); + expect(vBasename('dav://n/a/b.jpg'), 'b.jpg'); + }); +} diff --git a/test/support/dav_server.dart b/test/support/dav_server.dart index 6b01eea..89d77c2 100644 --- a/test/support/dav_server.dart +++ b/test/support/dav_server.dart @@ -11,6 +11,10 @@ class TestDavServer { late HttpServer _server; final requests = []; + /// X-plore 비교 5-1 ② (10/21): GET 의 Range 를 따를지 (처음 false - 예전 시험은 Range 를 모르는 서버로) · 받은 Range 머리 + bool ranges = false; + final rangeHeaders = []; + TestDavServer(this.root, {this.user = 'user', this.pass = 'pass'}); String get url => 'http://127.0.0.1:${_server.port}/dav'; @@ -76,6 +80,19 @@ class TestDavServer { res.statusCode = 404; break; } + final range = req.headers.value(HttpHeaders.rangeHeader); + if (range != null) rangeHeaders.add(range); + final m = range == null ? null : RegExp(r'^bytes=(\d+)-(\d*)$').firstMatch(range); + if (ranges && m != null) { + final len = File(local).lengthSync(); + final start = int.parse(m[1]!); + final end = m[2]!.isEmpty ? len - 1 : int.parse(m[2]!).clamp(0, len - 1); + res.statusCode = 206; + res.headers.set(HttpHeaders.contentRangeHeader, 'bytes $start-$end/$len'); + res.contentLength = end - start + 1; + await res.addStream(File(local).openRead(start, end + 1)); + break; + } res.contentLength = File(local).lengthSync(); await res.addStream(File(local).openRead()); case 'PUT':