WIP 81 파일 탐색기 빠른 보기: Space (TC 는 F3) · 길게 누르기 [미리 보기] - 동영상 썸네일 · 길이 · 코덱, 그림, 글 앞 64KB (검토 대기)

- 관리자 지시 (F10): 미리 보기 창 / PC 에서 Space 로 빠른 보기 (썸네일 · 길이 · 코덱).
  관리자 판단: 띄우는 창 (옆에 붙는 칸은 넓은 화면용으로 나중에 따로).
- 여는 곳: Windows 탐색기 · X-plore 모양은 Space (빠른 필터가 닫혀 있을 때만 - 칸에 치는 중이면 빈칸), TC 모양은 F3 (Space 는 표시 그대로),
  길게 누르기 [미리 보기] (파일).
- 동영상 · 음악: 큰 썸네일 (가로 640, jj_thumbs 에 따로 기억) · 길이 · 영상 코덱 · 해상도 · 소리 (코덱 · 언어) · 자막 트랙.
  ffprobe 는 기다리는 동안 "정보를 읽는 중…", 20초 넘으면 "읽지 못했습니다" (창은 멈추지 않음).
- 그림: 그림 그대로. 글 파일 (txt · srt · ass · smi · log · json …): 앞 64KB, UTF-8 이 아니면 "미리 볼 수 없음".
- WebDAV: 썸네일 · ffprobe 없이 이름 · 크기 · 날짜만 (비밀번호를 명령 줄에 넣지 않게 - 관리자 판단).
- ← → (↑ ↓) 로 같은 폴더의 앞 · 다음 (지금 목록 차례 - 필터 · 정렬 반영), Space · Esc 로 닫기, Enter · [열기 · 재생].
- 시험: quick_look_test (글 앞부분 · CP949 · 64KB 경계, ← → · 열기 · Space 닫기), explorer_test 81 (Space · 필터 중 빈칸 · TC F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 12:50:14 +09:00
parent 60336d342d
commit b1f20914a0
7 changed files with 432 additions and 2 deletions

View File

@ -325,6 +325,7 @@
"WebDAV 에서 받는 중": "Downloading from WebDAV", "WebDAV 에서 받는 중": "Downloading from WebDAV",
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.",
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV files show only name, size and date",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: the app syncs directly instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files).", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: the app syncs directly instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files).",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).",
"WebM (동영상)": "WebM (video)", "WebM (동영상)": "WebM (video)",
@ -666,6 +667,7 @@
"그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}",
"그림을 바꿔 만들기": "Make from this picture", "그림을 바꿔 만들기": "Make from this picture",
"그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})", "그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})",
"그림을 열 수 없습니다": "Can't open the image",
"그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})", "그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})",
"그림을 읽을 수 없습니다": "Cannot read the image", "그림을 읽을 수 없습니다": "Cannot read the image",
"그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)",
@ -701,6 +703,7 @@
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.", "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.",
"기타 즐겨찾기": "Other bookmarks", "기타 즐겨찾기": "Other bookmarks",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
"길이": "Length",
"길이 {0}": "Length {0}", "길이 {0}": "Length {0}",
"끄기": "Off", "끄기": "Off",
"끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "When off (default), they are cleared when you leave the Tor screen (another screen, or the app goes to the background). They are always kept separate from the normal browser.", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "When off (default), they are cleared when you leave the Tor screen (another screen, or the app goes to the background). They are always kept separate from the normal browser.",
@ -856,6 +859,7 @@
"다음 (N)": "Next (N)", "다음 (N)": "Next (N)",
"다음 {0}개 기다림": "{0} more waiting", "다음 {0}개 기다림": "{0} more waiting",
"다음 페이지": "Next page", "다음 페이지": "Next page",
"다음 항목 (→)": "Next item (→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)",
"단계": "Steps", "단계": "Steps",
"단어 첫 글자만 크게 (Abc)": "Capitalize words (Abc)", "단어 첫 글자만 크게 (Abc)": "Capitalize words (Abc)",
@ -1071,7 +1075,9 @@
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed",
"묻기": "Ask", "묻기": "Ask",
"물어보지 않기": "Never ask", "물어보지 않기": "Never ask",
"미리 보기": "Quick look",
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)",
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
"미얀마어": "Burmese", "미얀마어": "Burmese",
"미지정": "Unspecified", "미지정": "Unspecified",
@ -1354,6 +1360,7 @@
"세로 4:5": "Portrait 4:5", "세로 4:5": "Portrait 4:5",
"세로 9:16": "Portrait 9:16", "세로 9:16": "Portrait 9:16",
"세로 고정": "Portrait lock", "세로 고정": "Portrait lock",
"소리": "Audio",
"속도 제한": "Speed limit", "속도 제한": "Speed limit",
"속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 (KB/s)": "Speed limit (KB/s)",
"속도 제한 {0} KB/s": "Speed limit {0} KB/s", "속도 제한 {0} KB/s": "Speed limit {0} KB/s",
@ -1455,6 +1462,7 @@
"압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
"앞 항목 (←)": "Previous item (←)",
"앞으로": "Forward", "앞으로": "Forward",
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
"앞의 버튼 보기": "Previous buttons", "앞의 버튼 보기": "Previous buttons",
@ -1522,8 +1530,10 @@
"연도": "Year", "연도": "Year",
"열 수 없습니다: {0}": "Cannot open: {0}", "열 수 없습니다: {0}": "Cannot open: {0}",
"열기": "Open", "열기": "Open",
"열기 · 재생": "Open · Play",
"영구 삭제": "Delete permanently", "영구 삭제": "Delete permanently",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)",
"영상": "Video",
"영상 {0}": "Video {0}", "영상 {0}": "Video {0}",
"영상 · 음성 합치는 중": "Merging video · audio", "영상 · 음성 합치는 중": "Merging video · audio",
"영상 재생 오류: {0}": "Video playback error: {0}", "영상 재생 오류: {0}": "Video playback error: {0}",
@ -1868,6 +1878,7 @@
"자막 이동 실패: {0} ({1})": "Failed to move subtitle: {0} ({1})", "자막 이동 실패: {0} ({1})": "Failed to move subtitle: {0} ({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "Subtitles saved: {0} ({1}, {2} lines){3}", "자막 저장: {0} ({1}, {2}개 줄){3}": "Subtitles saved: {0} ({1}, {2} lines){3}",
"자막 크기": "Subtitle size", "자막 크기": "Subtitle size",
"자막 트랙 {0}개: {1}": "{0} subtitle tracks: {1}",
"자막 파일 선택": "Choose subtitle files", "자막 파일 선택": "Choose subtitle files",
"자막 파일 추가": "Add subtitle files", "자막 파일 추가": "Add subtitle files",
"자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})", "자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})",
@ -1969,6 +1980,8 @@
"정말 되돌릴까요?": "Really roll back?", "정말 되돌릴까요?": "Really roll back?",
"정말 초기화할까요?": "Really reset?", "정말 초기화할까요?": "Really reset?",
"정보": "Info", "정보": "Info",
"정보를 읽는 중…": "Reading info…",
"정보를 읽지 못했습니다": "Couldn't read the info",
"정사각 1:1": "Square 1:1", "정사각 1:1": "Square 1:1",
"정지": "Stop", "정지": "Stop",
"정하기": "Set", "정하기": "Set",

View File

@ -325,6 +325,7 @@
"WebDAV 에서 받는 중": "WebDAV からダウンロード中", "WebDAV 에서 받는 중": "WebDAV からダウンロード中",
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV のファイルは名前・サイズ・日付のみ表示します",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。",
"WebM (동영상)": "WebM(動画)", "WebM (동영상)": "WebM(動画)",
@ -666,6 +667,7 @@
"그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}",
"그림을 바꿔 만들기": "画像を変えて作成", "그림을 바꿔 만들기": "画像を変えて作成",
"그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})", "그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})",
"그림을 열 수 없습니다": "画像を開けません",
"그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})", "그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})",
"그림을 읽을 수 없습니다": "画像を読み込めません", "그림을 읽을 수 없습니다": "画像を読み込めません",
"그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)",
@ -701,6 +703,7 @@
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。", "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。",
"기타 즐겨찾기": "その他のブックマーク", "기타 즐겨찾기": "その他のブックマーク",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
"길이": "長さ",
"길이 {0}": "長さ {0}", "길이 {0}": "長さ {0}",
"끄기": "オフ", "끄기": "オフ",
"끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "オフ (既定) の場合は Tor 画面を離れるとき (別の画面 · アプリがバックグラウンドへ) に消去します。通常のブラウザーとは常に別です。", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "オフ (既定) の場合は Tor 画面を離れるとき (別の画面 · アプリがバックグラウンドへ) に消去します。通常のブラウザーとは常に別です。",
@ -856,6 +859,7 @@
"다음 (N)": "次へ(N)", "다음 (N)": "次へ(N)",
"다음 {0}개 기다림": "次の {0} 件が待機中", "다음 {0}개 기다림": "次の {0} 件が待機中",
"다음 페이지": "次のページ", "다음 페이지": "次のページ",
"다음 항목 (→)": "次の項目 (→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)",
"단계": "ステップ", "단계": "ステップ",
"단어 첫 글자만 크게 (Abc)": "単語の先頭だけ大文字 (Abc)", "단어 첫 글자만 크게 (Abc)": "単語の先頭だけ大文字 (Abc)",
@ -1071,7 +1075,9 @@
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません",
"묻기": "確認する", "묻기": "確認する",
"물어보지 않기": "尋ねない", "물어보지 않기": "尋ねない",
"미리 보기": "クイックプレビュー",
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)",
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
"미얀마어": "ミャンマー語", "미얀마어": "ミャンマー語",
"미지정": "未指定", "미지정": "未指定",
@ -1354,6 +1360,7 @@
"세로 4:5": "縦 4:5", "세로 4:5": "縦 4:5",
"세로 9:16": "縦 9:16", "세로 9:16": "縦 9:16",
"세로 고정": "縦向き固定", "세로 고정": "縦向き固定",
"소리": "音声",
"속도 제한": "速度制限", "속도 제한": "速度制限",
"속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 (KB/s)": "速度制限 (KB/s)",
"속도 제한 {0} KB/s": "速度制限 {0} KB/s", "속도 제한 {0} KB/s": "速度制限 {0} KB/s",
@ -1455,6 +1462,7 @@
"압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"앞 항목 (←)": "前の項目 (←)",
"앞으로": "進む", "앞으로": "進む",
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
"앞의 버튼 보기": "前のボタンを表示", "앞의 버튼 보기": "前のボタンを表示",
@ -1522,8 +1530,10 @@
"연도": "年", "연도": "年",
"열 수 없습니다: {0}": "開けません: {0}", "열 수 없습니다: {0}": "開けません: {0}",
"열기": "開く", "열기": "開く",
"열기 · 재생": "開く・再生",
"영구 삭제": "完全に削除", "영구 삭제": "完全に削除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)",
"영상": "映像",
"영상 {0}": "映像 {0}", "영상 {0}": "映像 {0}",
"영상 · 음성 합치는 중": "映像・音声を結合中", "영상 · 음성 합치는 중": "映像・音声を結合中",
"영상 재생 오류: {0}": "動画再生エラー: {0}", "영상 재생 오류: {0}": "動画再生エラー: {0}",
@ -1868,6 +1878,7 @@
"자막 이동 실패: {0} ({1})": "字幕の移動失敗: {0}({1})", "자막 이동 실패: {0} ({1})": "字幕の移動失敗: {0}({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "字幕を保存: {0}({1}、{2}行){3}", "자막 저장: {0} ({1}, {2}개 줄){3}": "字幕を保存: {0}({1}、{2}行){3}",
"자막 크기": "字幕サイズ", "자막 크기": "字幕サイズ",
"자막 트랙 {0}개: {1}": "字幕トラック {0} 個: {1}",
"자막 파일 선택": "字幕ファイルを選択", "자막 파일 선택": "字幕ファイルを選択",
"자막 파일 추가": "字幕ファイルを追加", "자막 파일 추가": "字幕ファイルを追加",
"자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})", "자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})",
@ -1969,6 +1980,8 @@
"정말 되돌릴까요?": "本当に戻しますか?", "정말 되돌릴까요?": "本当に戻しますか?",
"정말 초기화할까요?": "本当に初期化しますか?", "정말 초기화할까요?": "本当に初期化しますか?",
"정보": "情報", "정보": "情報",
"정보를 읽는 중…": "情報を読み込み中…",
"정보를 읽지 못했습니다": "情報を読み取れませんでした",
"정사각 1:1": "正方形 1:1", "정사각 1:1": "正方形 1:1",
"정지": "停止", "정지": "停止",
"정하기": "設定", "정하기": "設定",

View File

@ -325,6 +325,7 @@
"WebDAV 에서 받는 중": "正在从 WebDAV 下载", "WebDAV 에서 받는 중": "正在从 WebDAV 下载",
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。",
"WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV 文件仅显示名称、大小和日期",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。",
"WebM (동영상)": "WebM(视频)", "WebM (동영상)": "WebM(视频)",
@ -666,6 +667,7 @@
"그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}",
"그림을 바꿔 만들기": "基于图片生成", "그림을 바꿔 만들기": "基于图片生成",
"그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})", "그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})",
"그림을 열 수 없습니다": "无法打开图片",
"그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})", "그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})",
"그림을 읽을 수 없습니다": "无法读取图片", "그림을 읽을 수 없습니다": "无法读取图片",
"그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)",
@ -701,6 +703,7 @@
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。", "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。",
"기타 즐겨찾기": "其他书签", "기타 즐겨찾기": "其他书签",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
"길이": "时长",
"길이 {0}": "时长 {0}", "길이 {0}": "时长 {0}",
"끄기": "关闭", "끄기": "关闭",
"끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "关闭时(默认)在离开 Tor 页面时(切换到其他页面或应用转到后台)清除。始终与普通浏览器分开。", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "关闭时(默认)在离开 Tor 页面时(切换到其他页面或应用转到后台)清除。始终与普通浏览器分开。",
@ -856,6 +859,7 @@
"다음 (N)": "下一个(N)", "다음 (N)": "下一个(N)",
"다음 {0}개 기다림": "另有 {0} 个等待中", "다음 {0}개 기다림": "另有 {0} 个等待中",
"다음 페이지": "下一页", "다음 페이지": "下一页",
"다음 항목 (→)": "下一项 (→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)",
"단계": "步数", "단계": "步数",
"단어 첫 글자만 크게 (Abc)": "单词首字母大写 (Abc)", "단어 첫 글자만 크게 (Abc)": "单词首字母大写 (Abc)",
@ -1071,7 +1075,9 @@
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名",
"묻기": "询问", "묻기": "询问",
"물어보지 않기": "不询问", "물어보지 않기": "不询问",
"미리 보기": "快速预览",
"미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리 볼 수 없습니다: {0}": "无法预览: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)",
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
"미얀마어": "缅甸语", "미얀마어": "缅甸语",
"미지정": "未指定", "미지정": "未指定",
@ -1354,6 +1360,7 @@
"세로 4:5": "竖屏 4:5", "세로 4:5": "竖屏 4:5",
"세로 9:16": "竖屏 9:16", "세로 9:16": "竖屏 9:16",
"세로 고정": "锁定竖屏", "세로 고정": "锁定竖屏",
"소리": "音频",
"속도 제한": "速度限制", "속도 제한": "速度限制",
"속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 (KB/s)": "速度限制 (KB/s)",
"속도 제한 {0} KB/s": "速度限制 {0} KB/s", "속도 제한 {0} KB/s": "速度限制 {0} KB/s",
@ -1455,6 +1462,7 @@
"압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"앞 항목 (←)": "上一项 (←)",
"앞으로": "前进", "앞으로": "前进",
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
"앞의 버튼 보기": "前面的按钮", "앞의 버튼 보기": "前面的按钮",
@ -1522,8 +1530,10 @@
"연도": "年份", "연도": "年份",
"열 수 없습니다: {0}": "无法打开: {0}", "열 수 없습니다: {0}": "无法打开: {0}",
"열기": "打开", "열기": "打开",
"열기 · 재생": "打开 · 播放",
"영구 삭제": "永久删除", "영구 삭제": "永久删除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)",
"영상": "视频",
"영상 {0}": "视频 {0}", "영상 {0}": "视频 {0}",
"영상 · 음성 합치는 중": "正在合并视频 · 音频", "영상 · 음성 합치는 중": "正在合并视频 · 音频",
"영상 재생 오류: {0}": "视频播放错误: {0}", "영상 재생 오류: {0}": "视频播放错误: {0}",
@ -1868,6 +1878,7 @@
"자막 이동 실패: {0} ({1})": "字幕移动失败: {0}({1})", "자막 이동 실패: {0} ({1})": "字幕移动失败: {0}({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "已保存字幕: {0}({1},{2} 行){3}", "자막 저장: {0} ({1}, {2}개 줄){3}": "已保存字幕: {0}({1},{2} 行){3}",
"자막 크기": "字幕大小", "자막 크기": "字幕大小",
"자막 트랙 {0}개: {1}": "{0} 个字幕轨道: {1}",
"자막 파일 선택": "选择字幕文件", "자막 파일 선택": "选择字幕文件",
"자막 파일 추가": "添加字幕文件", "자막 파일 추가": "添加字幕文件",
"자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})", "자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})",
@ -1969,6 +1980,8 @@
"정말 되돌릴까요?": "确定要回退吗?", "정말 되돌릴까요?": "确定要回退吗?",
"정말 초기화할까요?": "确定要重置吗?", "정말 초기화할까요?": "确定要重置吗?",
"정보": "信息", "정보": "信息",
"정보를 읽는 중…": "正在读取信息…",
"정보를 읽지 못했습니다": "无法读取信息",
"정사각 1:1": "正方形 1:1", "정사각 1:1": "正方形 1:1",
"정지": "停止", "정지": "停止",
"정하기": "设置", "정하기": "设置",

View File

@ -44,6 +44,7 @@ import '../core/reader_sources.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'explorer_bookmarks.dart'; import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'quick_look.dart';
import 'batch_rename_dialog.dart'; import 'batch_rename_dialog.dart';
import '../core/folder_size.dart'; import '../core/folder_size.dart';
import '../core/batch_rename.dart'; import '../core/batch_rename.dart';
@ -1393,6 +1394,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')),
// 81: 빠른 보기 (PC 는 Space · TC 는 F3)
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')),
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
@ -1480,6 +1483,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
case 'select': case 'select':
setState(() => _selecting = true); setState(() => _selecting = true);
if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e);
case 'quickLook':
await _quickLook(pane, e.path);
case 'folderSize': case 'folderSize':
if (_sizes.busy(e.path)) { if (_sizes.busy(e.path)) {
_sizes.cancel(e.path); _sizes.cancel(e.path);
@ -3626,9 +3631,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
// Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼) // Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼)
const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true), const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true),
const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false), const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false),
// 81: 빠른 보기 (TC 의 보기 키)
const SingleActivator(LogicalKeyboardKey.f3): () => _quickLook(_pane),
}; };
} }
return { return {
// 81: 빠른 보기 (Windows · macOS 처럼 Space)
const SingleActivator(LogicalKeyboardKey.space): () => _quickLook(_pane),
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh),
const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false), const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false),
const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true), const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true),
@ -3640,6 +3649,26 @@ class _ExplorerPageState extends State<ExplorerPage> {
}; };
} }
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
Future<void> _quickLook(_Pane pane, [String? path]) async {
final f = path ?? pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = _shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
await showDialog<void>(
context: context,
builder: (_) => QuickLook(
entries: list,
index: i,
probe: c.services.mediaTool.probe,
thumbnail: (e) => _BigThumbs.of(c, e),
imageExts: c.settings.imageExts,
onOpen: (e) => e.isDir ? unawaited(_goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)),
),
);
}
/// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로 /// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로
Future<void> _markFocused({required bool next}) async { Future<void> _markFocused({required bool next}) async {
final pane = _pane; final pane = _pane;
@ -3677,8 +3706,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
final ch = ev.character; final ch = ev.character;
final kb = HardwareKeyboard.instance; final kb = HardwareKeyboard.instance;
if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored; if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored;
// 83: TC 모양에서 Space 는 표시 (빠른 필터가 아니라) // 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자)
if (ch == ' ' && _tcKeys && !_panes[_active].filterOpen) return KeyEventResult.ignored; if (ch == ' ' && !widget.rsync && !_panes[_active].filterOpen) return KeyEventResult.ignored;
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
if (_typing) return KeyEventResult.ignored; if (_typing) return KeyEventResult.ignored;
@ -4435,6 +4464,33 @@ class _Thumbs {
} }
} }
/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다
class _BigThumbs {
static final _cache = <String, Future<File?>>{};
static final _q = _Queue(1);
static Future<File?> of(AppController c, FileEntry e) {
final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640';
return _cache[key] ??= _q.run(() async {
try {
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs'));
await dir.create(recursive: true);
final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg'));
if (await out.exists() && await out.length() > 0) return out;
for (final at in ['5', '0']) {
await c.services.mediaTool.runFfmpeg([
'-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, //
'-frames:v', '1', '-vf', 'scale=640:-2', '-y', out.path,
]);
if (await out.exists() && await out.length() > 0) return out;
}
} catch (_) {}
_cache.remove(key);
return null;
});
}
}
class _Thumb extends StatelessWidget { class _Thumb extends StatelessWidget {
final AppController c; final AppController c;
final FileEntry entry; final FileEntry entry;

216
lib/ui/quick_look.dart Normal file
View File

@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/file_ops.dart';
import '../core/models.dart';
import '../core/playlist.dart' show isAudioFile, isVideoFile;
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 81: 빠른 보기에서 글로 보는 확장자
const textExtensions = ['txt', 'srt', 'ass', 'ssa', 'vtt', 'smi', 'log', 'md', 'json', 'xml', 'csv', 'ini', 'nfo'];
/// 81: 글 파일의 앞 64KB (UTF-8). 글자 코드가 깨졌으면 null - 끝에서 잘린 한 글자는 깨진 것으로 치지 않는다
Future<String?> readTextHead(String path, {int limit = 64 * 1024}) async {
final bytes = <int>[];
await for (final chunk in File(path).openRead(0, limit)) {
bytes.addAll(chunk);
}
final text = const Utf8Decoder(allowMalformed: true).convert(bytes);
final bad = '<EFBFBD>'.allMatches(text).length;
final cut = bytes.length >= limit && text.endsWith('<EFBFBD>') ? 1 : 0;
return bad - cut > 0 ? null : (cut > 0 ? text.substring(0, text.length - 1) : text);
}
/// 81: 빠른 보기 (Space · TC 는 F3 · 길게 누르기 [미리 보기]) - 동영상은 큰 썸네일 · 길이 · 해상도 · 코덱, 그림은 그림,
/// 작은 글 파일은 앞부분, 그 밖은 크기 · 날짜. ← → 로 같은 폴더의 앞 · 다음 항목, Esc · Space 로 닫기, [열기].
/// 파일을 바꾸지 않는다 (읽기만).
class QuickLook extends StatefulWidget {
/// 같은 폴더에 보이는 항목 (차례대로) · 처음 보일 것
final List<FileEntry> entries;
final int index;
/// 동영상 정보 (FFprobe) · 큰 썸네일 (없으면 null)
final Future<MediaInfo> Function(String path) probe;
final Future<File?> Function(FileEntry e) thumbnail;
/// 그림으로 볼 확장자
final List<String> imageExts;
/// [열기] (닫은 뒤 탐색기가 연다)
final void Function(FileEntry e) onOpen;
const QuickLook({
super.key,
required this.entries,
required this.index,
required this.probe,
required this.thumbnail,
required this.imageExts,
required this.onOpen,
});
@override
State<QuickLook> createState() => _QuickLookState();
}
class _QuickLookState extends State<QuickLook> {
late int _i = widget.index;
final _focus = FocusNode(debugLabel: 'quickLook');
FileEntry get _e => widget.entries[_i];
@override
void dispose() {
_focus.dispose();
super.dispose();
}
void _go(int d) {
final n = _i + d;
if (n < 0 || n >= widget.entries.length) return;
setState(() => _i = n);
}
static String _date(DateTime d) {
String two(int x) => x.toString().padLeft(2, '0');
return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}';
}
static String _dur(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
String two(int x) => x.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
}
Widget _body(FileEntry e) {
final dav = isDav(e.path);
if (e.isDir) return _info([tr('폴더')]);
if (!dav && (isVideoFile(e.path) || isAudioFile(e.path))) return _media(e);
if (!dav && widget.imageExts.contains(e.ext)) {
return Image.file(File(e.path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _info([tr('그림을 열 수 없습니다')]));
}
// 관리자 판단: 글 파일은 앞 64KB 만, 글자 코드가 깨지면 "미리 볼 수 없음"
if (!dav && e.size > 0 && textExtensions.contains(e.ext)) {
return FutureBuilder<String?>(
future: readTextHead(e.path),
builder: (_, s) => s.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator())
: s.data == null
? _info([tr('미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)')])
: SingleChildScrollView(
child: SelectableText(s.data!, style: const TextStyle(fontFamily: 'Consolas', fontSize: 12)),
),
);
}
return _info([if (dav) tr('WebDAV 파일은 이름 · 크기 · 날짜만 봅니다')]);
}
Widget _info(List<String> lines) => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(_e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, size: 56, color: JjColors.textDim),
for (final l in lines) Text(l, textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)),
]),
);
Widget _media(FileEntry e) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (isVideoFile(e.path))
Expanded(
child: FutureBuilder<File?>(
future: widget.thumbnail(e),
builder: (_, s) => s.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator())
: s.data == null
? const Center(child: Icon(Icons.movie_outlined, size: 56, color: JjColors.textDim))
: Image.file(s.data!, fit: BoxFit.contain),
),
),
const SizedBox(height: 8),
FutureBuilder<MediaInfo>(
// 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 20초 넘으면 못 읽음)
future: widget.probe(e.path).timeout(const Duration(seconds: 20)),
builder: (_, s) {
if (s.connectionState != ConnectionState.done) return Text(tr('정보를 읽는 중…'), style: const TextStyle(color: JjColors.textDim));
final info = s.data;
if (info == null) return Text(tr('정보를 읽지 못했습니다'), style: const TextStyle(color: JjColors.textDim));
final v = info.ofType('video').where((x) => (x.width ?? 0) > 0).firstOrNull;
final audio = info.ofType('audio');
final subs = info.ofType('subtitle');
return SelectableText(
[
if (info.duration != null) '${tr('길이')}: ${_dur(info.duration!)}',
if (v != null) '${tr('영상')}: ${v.codec} · ${v.width}×${v.height}',
if (audio.isNotEmpty)
'${tr('소리')}: ${audio.map((a) => [a.codec, if (a.language.isNotEmpty) a.language].join(' ')).join(', ')}',
if (subs.isNotEmpty)
trf('자막 트랙 {0}개: {1}', [subs.length, subs.map((x) => [x.codec, if (x.language.isNotEmpty) x.language].join(' ')).join(', ')]),
].join('\n'),
style: const TextStyle(fontSize: 13),
);
},
),
]);
@override
Widget build(BuildContext context) {
final e = _e;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _go(-1),
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _go(1),
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _go(-1),
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _go(1),
const SingleActivator(LogicalKeyboardKey.space): () => Navigator.pop(context),
const SingleActivator(LogicalKeyboardKey.enter): () {
Navigator.pop(context);
widget.onOpen(e);
},
},
child: Focus(
focusNode: _focus,
autofocus: true,
child: AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
title: Row(children: [
Expanded(child: Text(e.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16))),
IconButton(tooltip: tr('앞 항목 (←)'), onPressed: _i > 0 ? () => _go(-1) : null, icon: const Icon(Icons.chevron_left)),
Text('${_i + 1}/${widget.entries.length}', style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
IconButton(
tooltip: tr('다음 항목 (→)'),
onPressed: _i < widget.entries.length - 1 ? () => _go(1) : null,
icon: const Icon(Icons.chevron_right)),
]),
content: SizedBox(
width: 640,
height: 420,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(
[if (!e.isDir) formatSize(e.size), if (e.modified.year > 1) _date(e.modified), vDirname(e.path)].join(' · '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
const SizedBox(height: 8),
Expanded(child: KeyedSubtree(key: ValueKey(e.path), child: _body(e))),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))),
FilledButton(
onPressed: () {
Navigator.pop(context);
widget.onOpen(e);
},
child: Text(e.isDir ? tr('열기') : tr('열기 · 재생')),
),
],
),
),
);
}
}

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize;
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/explorer_search.dart';
import 'package:jj_mkvmaker/ui/quick_look.dart';
import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
@ -1013,6 +1014,42 @@ void main() {
expect(find.textContaining('담았습니다'), findsOneWidget); expect(find.textContaining('담았습니다'), findsOneWidget);
}); });
testWidgets('81: Space 로 빠른 보기 (글 파일 앞부분) · 필터에 치는 중에는 그냥 빈칸 · TC 는 F3', (tester) async {
c.settings.explorerStyle = 'windows';
await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space));
await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty);
expect(find.byType(QuickLook), findsOneWidget);
await settle(tester, () => find.text('hello').evaluate().isNotEmpty);
expect(find.text('hello'), findsOneWidget, reason: '글 파일 앞부분');
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape));
await calm(tester);
expect(find.byType(QuickLook), findsNothing);
// 빠른 필터에 치는 중이면 Space 는 칸의 글자 (빠른 보기를 열지 않음)
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.keyD));
await calm(tester);
expect(find.byType(TextField), findsOneWidget);
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space));
await calm(tester);
expect(find.byType(QuickLook), findsNothing);
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape));
await calm(tester);
// TC: F3
await c.updateSettings((s) => s.explorerStyle = 'totalcmd');
await calm(tester);
await act(tester, () => tester.tap(find.text('doc')));
await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f3));
await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty);
expect(find.byType(QuickLook), findsOneWidget);
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape));
await calm(tester);
});
// P0 (15 지적 10/17): 글자 칸에 치는 중에는 파일 단축키가 키를 가로채지 않는다 (예전: 필터 칸의 Delete → 파일 지우기 창) // P0 (15 지적 10/17): 글자 칸에 치는 중에는 파일 단축키가 키를 가로채지 않는다 (예전: 필터 칸의 Delete → 파일 지우기 창)
for (final style in const ['windows', 'xplore', 'totalcmd']) { for (final style in const ['windows', 'xplore', 'totalcmd']) {
testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async { testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async {

82
test/quick_look_test.dart Normal file
View File

@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/ui/quick_look.dart';
import 'package:path/path.dart' as p;
/// 81: 빠른 보기 - 글 파일은 앞 64KB (깨지면 "미리 볼 수 없음"), ← → 로 앞 · 다음, Space 로 닫기, [열기]
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ql81_'));
tearDown(() => tmp.deleteSync(recursive: true));
test('글 앞부분: UTF-8 은 그대로 · 다른 글자 코드 (CP949) 는 null · 64KB 끝에서 잘린 한 글자는 괜찮음', () async {
final a = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('안녕 hello');
expect(await readTextHead(a.path), '안녕 hello');
final b = File(p.join(tmp.path, 'b.smi'))..writeAsBytesSync([0xBE, 0xC8, 0xB3, 0xE7]); // CP949 "안녕"
expect(await readTextHead(b.path), isNull);
// 64KB 경계에 3바이트 글자가 걸리게
final c = File(p.join(tmp.path, 'c.txt'))..writeAsStringSync('${'x' * (64 * 1024 - 1)}가나다');
final head = await readTextHead(c.path);
expect(head, isNotNull);
expect(head!.length, 64 * 1024 - 1);
});
testWidgets('← → 로 앞 · 다음 (보이는 차례), Space 로 닫기, [열기]', (tester) async {
tester.view.physicalSize = const Size(1000, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final now = DateTime(2026, 10, 17);
final entries = [
FileEntry(p.join(tmp.path, 'one.bin'), isDir: false, size: 10, modified: now),
FileEntry(p.join(tmp.path, 'two.bin'), isDir: false, size: 20, modified: now),
];
final opened = <String>[];
final nav = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold()));
showDialog<void>(
context: nav.currentContext!,
builder: (_) => QuickLook(
entries: entries,
index: 0,
probe: (_) async => const MediaInfo(streams: []),
thumbnail: (_) async => null,
imageExts: const ['jpg'],
onOpen: (e) => opened.add(e.name),
),
);
await tester.pumpAndSettle();
expect(find.text('one.bin'), findsOneWidget);
expect(find.text('1/2'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(find.text('two.bin'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(find.text('2/2'), findsOneWidget, reason: '끝에서 더 가지 않음');
await tester.tap(find.text('열기 · 재생'));
await tester.pumpAndSettle();
expect(opened, ['two.bin']);
expect(find.byType(QuickLook), findsNothing);
showDialog<void>(
context: nav.currentContext!,
builder: (_) => QuickLook(
entries: entries,
index: 1,
probe: (_) async => const MediaInfo(streams: []),
thumbnail: (_) async => null,
imageExts: const ['jpg'],
onOpen: (_) {},
),
);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(find.byType(QuickLook), findsNothing, reason: 'Space 로 닫기');
});
}