WIP X-plore 비교 5-1 ② 원격 썸네일 (관리자 지시 10/21): WebDAV · SFTP · SMB · FTP 의 그림 (30MB 아래) · 동영상을 X-plore 모양 목록에서 작은 그림으로 - 환경 설정 > 파일 탐색기 "원격 썸네일" (Wi-Fi 에서만 (처음 값) · 늘 · 받지 않음) · 캐시 상한 (처음 200MB, 넘으면 오래 쓰지 않은 것부터) · [캐시 지우기]. 동시에 2개, 화면에서 사라진 줄은 시작하지 않음, 같은 것은 한 번. FFmpeg 가 이 기기 안 스트리밍 다리 (127.0.0.1, Range) 에서 일부만 읽음 - WebDAV 도 Range 로 (모르는 서버는 받으며 건너뜀), 비밀번호는 명령 줄에 넣지 않음. "Wi-Fi 에서만" = Android 의 데이터 요금제 판단 (isActiveNetworkMetered) - 내 판단: VPN 은 그 아래 망을 따름 (집 Wi-Fi 위의 Tailscale 은 받음, 모바일 데이터 위는 받지 않음), PC 는 늘 받음 · 시험 (동시 수 · 보이는 줄 · 상한 · 망 · Range 206/200 · 실제 FFmpeg 썸네일) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>xplore_p3b
parent
81e5906f76
commit
f92f801e7a
|
|
@ -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<String>("path") ?: "")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 に変更しました。",
|
||||
"크기를 재는 중 …": "サイズを計測中 …",
|
||||
"크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)",
|
||||
"크메르어": "クメール語",
|
||||
"큰 파일": "大きいファイル",
|
||||
|
|
|
|||
|
|
@ -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。",
|
||||
"크기를 재는 중 …": "正在计算大小 …",
|
||||
"크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)",
|
||||
"크메르어": "高棉语",
|
||||
"큰 파일": "大文件",
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,19 @@ Future<Map<String, String>?> _androidNetKinds() async {
|
|||
}
|
||||
}
|
||||
|
||||
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제 Wi-Fi). Android 만 묻고,
|
||||
/// 그 밖 (PC) 은 false. 물을 수 없으면 true (받지 않는 쪽으로). 시험에서 바꾼다
|
||||
Future<bool> Function() activeMeteredOf = _androidMetered;
|
||||
|
||||
Future<bool> _androidMetered() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
return await const MethodChannel('jj_mkvmaker/android').invokeMethod<bool>('activeMetered') ?? true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로
|
||||
bool isLanAddress(String ip, String interfaceName, Map<String, String>? kinds) {
|
||||
final k = kinds?[ip];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool> Function() allowed;
|
||||
|
||||
/// [path] 의 썸네일을 [out] (jpg) 에 만든다 - 만들었으면 true
|
||||
final Future<bool> Function(String path, String out) make;
|
||||
|
||||
RemoteThumbs({required this.dir, required this.maxBytes, required this.allowed, required this.make, this.parallel = 2});
|
||||
|
||||
final _done = <String, File?>{};
|
||||
final _waiting = Queue<_Job>();
|
||||
final _jobs = <String, _Job>{};
|
||||
int _running = 0;
|
||||
|
||||
/// 시험: 지금까지 [make] 를 부른 경로 · 동시에 돈 가장 큰 수
|
||||
final made = <String>[];
|
||||
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<File?> 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<void> _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<int> 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<void> _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<void> 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 = <bool Function()>[];
|
||||
final result = Completer<File?>();
|
||||
_Job(this.path, this.key, this.out);
|
||||
}
|
||||
|
|
@ -87,6 +87,29 @@ class DavException implements Exception {
|
|||
}
|
||||
|
||||
/// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic).
|
||||
/// [s] 의 앞 [start] 바이트를 건너뛰고 [length] 바이트 (null 이면 끝까지)
|
||||
Stream<List<int>> skipBytes(Stream<List<int>> 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<Stream<List<int>>> 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<void>();
|
||||
_fail(tr('받기'), res);
|
||||
}
|
||||
return skipBytes(idleRead(res), offset, length);
|
||||
}
|
||||
|
||||
/// 올리기 ([length] 를 알면 Content-Length 로)
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
|
|
|
|||
|
|
@ -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<ExplorerPage> {
|
|||
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'};
|
||||
|
|
|
|||
|
|
@ -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<RemoteThumbs>? _hub;
|
||||
|
||||
Future<RemoteThumbs> 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<bool> 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<bool> _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<RemoteThumb> createState() => _RemoteThumbState();
|
||||
}
|
||||
|
||||
class _RemoteThumbState extends State<RemoteThumb> {
|
||||
File? _file;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> _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<SettingsPage> {
|
|||
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<String>(
|
||||
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<int>(
|
||||
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('폴더 크기')),
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<void> until(bool Function() ok) async {
|
||||
for (var i = 0; i < 250 && !ok(); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
test('동시에 2개까지 · 화면에서 사라진 줄은 시작하지 않음 · 같은 것은 한 번만', () async {
|
||||
final gates = <String, Completer<void>>{};
|
||||
final h = hub(make: (path, out) async {
|
||||
await (gates[path] = Completer<void>()).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<void>.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<List<int>> 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');
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ class TestDavServer {
|
|||
late HttpServer _server;
|
||||
final requests = <String>[];
|
||||
|
||||
/// X-plore 비교 5-1 ② (10/21): GET 의 Range 를 따를지 (처음 false - 예전 시험은 Range 를 모르는 서버로) · 받은 Range 머리
|
||||
bool ranges = false;
|
||||
final rangeHeaders = <String>[];
|
||||
|
||||
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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue