diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4c2571d..07599d9 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -325,6 +325,7 @@ "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 의 동영상은 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 · --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)", @@ -666,6 +667,7 @@ "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", "그림을 바꿔 만들기": "Make from this picture", "그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})", + "그림을 열 수 없습니다": "Can't open the image", "그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})", "그림을 읽을 수 없습니다": "Cannot read the image", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", @@ -701,6 +703,7 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.", "기타 즐겨찾기": "Other bookmarks", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", + "길이": "Length", "길이 {0}": "Length {0}", "끄기": "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.", @@ -856,6 +859,7 @@ "다음 (N)": "Next (N)", "다음 {0}개 기다림": "{0} more waiting", "다음 페이지": "Next page", + "다음 항목 (→)": "Next item (→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)", "단계": "Steps", "단어 첫 글자만 크게 (Abc)": "Capitalize words (Abc)", @@ -1071,7 +1075,9 @@ "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed", "묻기": "Ask", "물어보지 않기": "Never ask", + "미리 보기": "Quick look", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}", + "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", "미지정": "Unspecified", @@ -1354,6 +1360,7 @@ "세로 4:5": "Portrait 4:5", "세로 9:16": "Portrait 9:16", "세로 고정": "Portrait lock", + "소리": "Audio", "속도 제한": "Speed limit", "속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 {0} KB/s": "Speed limit {0} KB/s", @@ -1455,6 +1462,7 @@ "압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", + "앞 항목 (←)": "Previous item (←)", "앞으로": "Forward", "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", "앞의 버튼 보기": "Previous buttons", @@ -1522,8 +1530,10 @@ "연도": "Year", "열 수 없습니다: {0}": "Cannot open: {0}", "열기": "Open", + "열기 · 재생": "Open · Play", "영구 삭제": "Delete permanently", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", + "영상": "Video", "영상 {0}": "Video {0}", "영상 · 음성 합치는 중": "Merging video · audio", "영상 재생 오류: {0}": "Video playback error: {0}", @@ -1868,6 +1878,7 @@ "자막 이동 실패: {0} ({1})": "Failed to move subtitle: {0} ({1})", "자막 저장: {0} ({1}, {2}개 줄){3}": "Subtitles saved: {0} ({1}, {2} lines){3}", "자막 크기": "Subtitle size", + "자막 트랙 {0}개: {1}": "{0} subtitle tracks: {1}", "자막 파일 선택": "Choose subtitle files", "자막 파일 추가": "Add subtitle files", "자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})", @@ -1969,6 +1980,8 @@ "정말 되돌릴까요?": "Really roll back?", "정말 초기화할까요?": "Really reset?", "정보": "Info", + "정보를 읽는 중…": "Reading info…", + "정보를 읽지 못했습니다": "Couldn't read the info", "정사각 1:1": "Square 1:1", "정지": "Stop", "정하기": "Set", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 53b3cb4..97f21c4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -325,6 +325,7 @@ "WebDAV 에서 받는 중": "WebDAV からダウンロード中", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", + "WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV のファイルは名前・サイズ・日付のみ表示します", "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 のみ反映)。", "WebM (동영상)": "WebM(動画)", @@ -666,6 +667,7 @@ "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", "그림을 바꿔 만들기": "画像を変えて作成", "그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})", + "그림을 열 수 없습니다": "画像を開けません", "그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})", "그림을 읽을 수 없습니다": "画像を読み込めません", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", @@ -701,6 +703,7 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。", "기타 즐겨찾기": "その他のブックマーク", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", + "길이": "長さ", "길이 {0}": "長さ {0}", "끄기": "オフ", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "オフ (既定) の場合は Tor 画面を離れるとき (別の画面 · アプリがバックグラウンドへ) に消去します。通常のブラウザーとは常に別です。", @@ -856,6 +859,7 @@ "다음 (N)": "次へ(N)", "다음 {0}개 기다림": "次の {0} 件が待機中", "다음 페이지": "次のページ", + "다음 항목 (→)": "次の項目 (→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)", "단계": "ステップ", "단어 첫 글자만 크게 (Abc)": "単語の先頭だけ大文字 (Abc)", @@ -1071,7 +1075,9 @@ "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません", "묻기": "確認する", "물어보지 않기": "尋ねない", + "미리 보기": "クイックプレビュー", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", + "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", "미지정": "未指定", @@ -1354,6 +1360,7 @@ "세로 4:5": "縦 4:5", "세로 9:16": "縦 9:16", "세로 고정": "縦向き固定", + "소리": "音声", "속도 제한": "速度制限", "속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 {0} KB/s": "速度制限 {0} KB/s", @@ -1455,6 +1462,7 @@ "압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", + "앞 항목 (←)": "前の項目 (←)", "앞으로": "進む", "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", "앞의 버튼 보기": "前のボタンを表示", @@ -1522,8 +1530,10 @@ "연도": "年", "열 수 없습니다: {0}": "開けません: {0}", "열기": "開く", + "열기 · 재생": "開く・再生", "영구 삭제": "完全に削除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", + "영상": "映像", "영상 {0}": "映像 {0}", "영상 · 음성 합치는 중": "映像・音声を結合中", "영상 재생 오류: {0}": "動画再生エラー: {0}", @@ -1868,6 +1878,7 @@ "자막 이동 실패: {0} ({1})": "字幕の移動失敗: {0}({1})", "자막 저장: {0} ({1}, {2}개 줄){3}": "字幕を保存: {0}({1}、{2}行){3}", "자막 크기": "字幕サイズ", + "자막 트랙 {0}개: {1}": "字幕トラック {0} 個: {1}", "자막 파일 선택": "字幕ファイルを選択", "자막 파일 추가": "字幕ファイルを追加", "자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})", @@ -1969,6 +1980,8 @@ "정말 되돌릴까요?": "本当に戻しますか?", "정말 초기화할까요?": "本当に初期化しますか?", "정보": "情報", + "정보를 읽는 중…": "情報を読み込み中…", + "정보를 읽지 못했습니다": "情報を読み取れませんでした", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하기": "設定", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 427d937..9ea6298 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -325,6 +325,7 @@ "WebDAV 에서 받는 중": "正在从 WebDAV 下载", "WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。", "WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。", + "WebDAV 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV 文件仅显示名称、大小和日期", "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 生效)。", "WebM (동영상)": "WebM(视频)", @@ -666,6 +667,7 @@ "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", "그림을 바꿔 만들기": "基于图片生成", "그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})", + "그림을 열 수 없습니다": "无法打开图片", "그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})", "그림을 읽을 수 없습니다": "无法读取图片", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", @@ -701,6 +703,7 @@ "기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。", "기타 즐겨찾기": "其他书签", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", + "길이": "时长", "길이 {0}": "时长 {0}", "끄기": "关闭", "끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.": "关闭时(默认)在离开 Tor 页面时(切换到其他页面或应用转到后台)清除。始终与普通浏览器分开。", @@ -856,6 +859,7 @@ "다음 (N)": "下一个(N)", "다음 {0}개 기다림": "另有 {0} 个等待中", "다음 페이지": "下一页", + "다음 항목 (→)": "下一项 (→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)", "단계": "步数", "단어 첫 글자만 크게 (Abc)": "单词首字母大写 (Abc)", @@ -1071,7 +1075,9 @@ "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名", "묻기": "询问", "물어보지 않기": "不询问", + "미리 보기": "快速预览", "미리 볼 수 없습니다: {0}": "无法预览: {0}", + "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", "미지정": "未指定", @@ -1354,6 +1360,7 @@ "세로 4:5": "竖屏 4:5", "세로 9:16": "竖屏 9:16", "세로 고정": "锁定竖屏", + "소리": "音频", "속도 제한": "速度限制", "속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 {0} KB/s": "速度限制 {0} KB/s", @@ -1455,6 +1462,7 @@ "압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축을 풀 수 없습니다: {0}": "无法解压: {0}", + "앞 항목 (←)": "上一项 (←)", "앞으로": "前进", "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", "앞의 버튼 보기": "前面的按钮", @@ -1522,8 +1530,10 @@ "연도": "年份", "열 수 없습니다: {0}": "无法打开: {0}", "열기": "打开", + "열기 · 재생": "打开 · 播放", "영구 삭제": "永久删除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", + "영상": "视频", "영상 {0}": "视频 {0}", "영상 · 음성 합치는 중": "正在合并视频 · 音频", "영상 재생 오류: {0}": "视频播放错误: {0}", @@ -1868,6 +1878,7 @@ "자막 이동 실패: {0} ({1})": "字幕移动失败: {0}({1})", "자막 저장: {0} ({1}, {2}개 줄){3}": "已保存字幕: {0}({1},{2} 行){3}", "자막 크기": "字幕大小", + "자막 트랙 {0}개: {1}": "{0} 个字幕轨道: {1}", "자막 파일 선택": "选择字幕文件", "자막 파일 추가": "添加字幕文件", "자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})", @@ -1969,6 +1980,8 @@ "정말 되돌릴까요?": "确定要回退吗?", "정말 초기화할까요?": "确定要重置吗?", "정보": "信息", + "정보를 읽는 중…": "正在读取信息…", + "정보를 읽지 못했습니다": "无法读取信息", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하기": "设置", diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 9d265e0..ba9cb79 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -44,6 +44,7 @@ import '../core/reader_sources.dart'; import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; +import 'quick_look.dart'; import 'batch_rename_dialog.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; @@ -1393,6 +1394,8 @@ class _ExplorerPageState extends State { // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), + // 81: 빠른 보기 (PC 는 Space · TC 는 F3) + if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), @@ -1480,6 +1483,8 @@ class _ExplorerPageState extends State { case 'select': setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); + case 'quickLook': + await _quickLook(pane, e.path); case 'folderSize': if (_sizes.busy(e.path)) { _sizes.cancel(e.path); @@ -3626,9 +3631,13 @@ class _ExplorerPageState extends State { // Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼) const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true), const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false), + // 81: 빠른 보기 (TC 의 보기 키) + const SingleActivator(LogicalKeyboardKey.f3): () => _quickLook(_pane), }; } return { + // 81: 빠른 보기 (Windows · macOS 처럼 Space) + const SingleActivator(LogicalKeyboardKey.space): () => _quickLook(_pane), const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false), const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true), @@ -3640,6 +3649,26 @@ class _ExplorerPageState extends State { }; } + /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 + Future _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( + 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] 면 다음 줄로 Future _markFocused({required bool next}) async { final pane = _pane; @@ -3677,8 +3706,8 @@ class _ExplorerPageState extends State { final ch = ev.character; final kb = HardwareKeyboard.instance; if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored; - // 83: TC 모양에서 Space 는 표시 (빠른 필터가 아니라) - if (ch == ' ' && _tcKeys && !_panes[_active].filterOpen) return KeyEventResult.ignored; + // 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자) + if (ch == ' ' && !widget.rsync && !_panes[_active].filterOpen) return KeyEventResult.ignored; if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 if (_typing) return KeyEventResult.ignored; @@ -4435,6 +4464,33 @@ class _Thumbs { } } +/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다 +class _BigThumbs { + static final _cache = >{}; + static final _q = _Queue(1); + + static Future 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 { final AppController c; final FileEntry entry; diff --git a/lib/ui/quick_look.dart b/lib/ui/quick_look.dart new file mode 100644 index 0000000..a9cc883 --- /dev/null +++ b/lib/ui/quick_look.dart @@ -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 readTextHead(String path, {int limit = 64 * 1024}) async { + final bytes = []; + await for (final chunk in File(path).openRead(0, limit)) { + bytes.addAll(chunk); + } + final text = const Utf8Decoder(allowMalformed: true).convert(bytes); + final bad = '�'.allMatches(text).length; + final cut = bytes.length >= limit && text.endsWith('�') ? 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 entries; + final int index; + + /// 동영상 정보 (FFprobe) · 큰 썸네일 (없으면 null) + final Future Function(String path) probe; + final Future Function(FileEntry e) thumbnail; + + /// 그림으로 볼 확장자 + final List 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 createState() => _QuickLookState(); +} + +class _QuickLookState extends State { + 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( + 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 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( + 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( + // 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 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('열기 · 재생')), + ), + ], + ), + ), + ); + } +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 023ef43..3273781 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -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/ui/explorer_page.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:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; @@ -1013,6 +1014,42 @@ void main() { 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 → 파일 지우기 창) for (final style in const ['windows', 'xplore', 'totalcmd']) { testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async { diff --git a/test/quick_look_test.dart b/test/quick_look_test.dart new file mode 100644 index 0000000..b00c157 --- /dev/null +++ b/test/quick_look_test.dart @@ -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 = []; + final nav = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold())); + showDialog( + 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( + 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 로 닫기'); + }); +}