문서 미리보기 컴포넌트 · 컴포넌트 설치 · 브라우저 위아래 · 화면 순서 버튼
- ComponentStore: GitHub 릴리스 "components" 의 components.json 을 읽어 설치 (받기 · SHA-256 확인 · 풀기) · 제거. 앱 데이터 폴더 components/(id) 에 둔다 (앱 업데이트에 지워지지 않게). - 문서 미리보기 (Windows): LibreOffice 26.2.6 (MSI 관리 설치, 관리자 권한 없이) + Temurin JRE 21 + H2Orestart (HWP · HWPX). DOC · DOCX · HWP · HWPX · XLS · XLSX · PPT · PPTX · ODT 등을 PDF 로 바꿔 보기. Android 는 다른 앱으로 연다. - 환경 설정 > 컴포넌트: 문서 미리보기 켜면 진행 창과 함께 설치, 끄면 지움. - 버전 목록: 이 기기용 설치 파일이 있는 버전 릴리스만 (components 릴리스 제외). - 웹 브라우저: 좌우로 밀기는 위쪽 막대에서만 (웹 페이지의 위 · 아래 스크롤과 다투던 것). - 화면 순서: 끌어 놓기 대신 ▲ ▼ (화면 배율 때문에 놓는 자리가 어긋나던 것). - 시험: component_store_test (JJ_COMPONENTS_URL 일 때 실제 설치 · HWP 변환), components_test 순서 버튼. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
ac8fd8ad9a
commit
70e2b87c11
|
|
@ -123,6 +123,7 @@
|
||||||
"PC 전체 CPU · 메모리 사용량": "Whole-PC CPU · memory usage",
|
"PC 전체 CPU · 메모리 사용량": "Whole-PC CPU · memory usage",
|
||||||
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "Whole-PC usage\nCPU {0}%\nMemory {1} / {2}GB ({3}%){4}",
|
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "Whole-PC usage\nCPU {0}%\nMemory {1} / {2}GB ({3}%){4}",
|
||||||
"PDF 로 만들기": "Make PDF",
|
"PDF 로 만들기": "Make PDF",
|
||||||
|
"PDF 로 바꾸는 중: {0}": "Converting to PDF: {0}",
|
||||||
"PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}",
|
"PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}",
|
||||||
"PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}",
|
"PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}",
|
||||||
"PDF 에 넣을 그림 선택": "Choose images to add to the PDF",
|
"PDF 에 넣을 그림 선택": "Choose images to add to the PDF",
|
||||||
|
|
@ -143,6 +144,7 @@
|
||||||
"Whisper small (정확)": "Whisper small (accurate)",
|
"Whisper small (정확)": "Whisper small (accurate)",
|
||||||
"Windows 기본 브라우저": "Windows default browser",
|
"Windows 기본 브라우저": "Windows default browser",
|
||||||
"Windows 기본 프로그램": "Windows default program",
|
"Windows 기본 프로그램": "Windows default program",
|
||||||
|
"Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Available on Windows only. On this device, documents open in another app.",
|
||||||
"Windows 탐색기": "Windows Explorer",
|
"Windows 탐색기": "Windows Explorer",
|
||||||
"YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list",
|
"YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list",
|
||||||
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)",
|
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)",
|
||||||
|
|
@ -201,14 +203,17 @@
|
||||||
"{0} 번역 중": "Translating {0}",
|
"{0} 번역 중": "Translating {0}",
|
||||||
"{0} 번역 중 {1}%": "Translating {0} {1}%",
|
"{0} 번역 중 {1}%": "Translating {0} {1}%",
|
||||||
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "Use the YouTube sign-in cookies of the {0} browser (Chrome · Edge may need to be closed to be read)",
|
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "Use the YouTube sign-in cookies of the {0} browser (Chrome · Edge may need to be closed to be read)",
|
||||||
|
"{0} 설치": "Install {0}",
|
||||||
"{0} 순으로 정렬{1}": "Sort by {0}{1}",
|
"{0} 순으로 정렬{1}": "Sort by {0}{1}",
|
||||||
"{0} 아래에 만듭니다": "Created under {0}",
|
"{0} 아래에 만듭니다": "Created under {0}",
|
||||||
"{0} 안의 파일": "Files in {0}",
|
"{0} 안의 파일": "Files in {0}",
|
||||||
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
|
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
|
||||||
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
||||||
"{0} 외 {1}개": "{0} and {1} more",
|
"{0} 외 {1}개": "{0} and {1} more",
|
||||||
|
"{0} 을(를) 설치했습니다.": "Installed {0}.",
|
||||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
|
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
|
||||||
"{0} 전체": "All of {0}",
|
"{0} 전체": "All of {0}",
|
||||||
|
"{0} 제거": "Remove {0}",
|
||||||
"{0} 추가": "Add {0}",
|
"{0} 추가": "Add {0}",
|
||||||
"{0} 파일": "{0} file",
|
"{0} 파일": "{0} file",
|
||||||
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)",
|
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)",
|
||||||
|
|
@ -344,6 +349,7 @@
|
||||||
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)",
|
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)",
|
||||||
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
|
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
|
||||||
"내려받은 rsync 지우기": "Delete downloaded rsync",
|
"내려받은 rsync 지우기": "Delete downloaded rsync",
|
||||||
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.",
|
||||||
"내보냈습니다: {0}": "Exported: {0}",
|
"내보냈습니다: {0}": "Exported: {0}",
|
||||||
"내역": "History",
|
"내역": "History",
|
||||||
"내역 (최근 폴더)": "History (recent folders)",
|
"내역 (최근 폴더)": "History (recent folders)",
|
||||||
|
|
@ -517,11 +523,13 @@
|
||||||
"목록 보기": "View list",
|
"목록 보기": "View list",
|
||||||
"목록에서 제거": "Remove from list",
|
"목록에서 제거": "Remove from list",
|
||||||
"목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)",
|
"목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)",
|
||||||
|
"목록을 받는 중": "Getting the list",
|
||||||
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
|
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
|
||||||
"몽골어": "Mongolian",
|
"몽골어": "Mongolian",
|
||||||
"무한": "Unlimited",
|
"무한": "Unlimited",
|
||||||
"문서 미리보기": "Document preview",
|
"문서 미리보기": "Document preview",
|
||||||
"문자셋 경고": "Charset warning",
|
"문자셋 경고": "Charset warning",
|
||||||
|
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
||||||
"미얀마어": "Burmese",
|
"미얀마어": "Burmese",
|
||||||
"미지정": "Unspecified",
|
"미지정": "Unspecified",
|
||||||
|
|
@ -574,6 +582,7 @@
|
||||||
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
|
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
|
||||||
"변환": "Convert",
|
"변환": "Convert",
|
||||||
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
|
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
|
||||||
|
"보기": "View",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
||||||
"복사": "Copy",
|
"복사": "Copy",
|
||||||
|
|
@ -662,6 +671,7 @@
|
||||||
"설치하는 중": "Installing",
|
"설치하는 중": "Installing",
|
||||||
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)",
|
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)",
|
||||||
"설치하지 못했습니다.\n{0}": "Could not install.\n{0}",
|
"설치하지 못했습니다.\n{0}": "Could not install.\n{0}",
|
||||||
|
"설치하지 못했습니다: {0}": "Could not install: {0}",
|
||||||
"성공: {0}": "Succeeded: {0}",
|
"성공: {0}": "Succeeded: {0}",
|
||||||
"세로 4:5": "Portrait 4:5",
|
"세로 4:5": "Portrait 4:5",
|
||||||
"세로 9:16": "Portrait 9:16",
|
"세로 9:16": "Portrait 9:16",
|
||||||
|
|
@ -843,6 +853,7 @@
|
||||||
"위 폴더": "Parent folder",
|
"위 폴더": "Parent folder",
|
||||||
"위로": "Up",
|
"위로": "Up",
|
||||||
"위아래": "Top and bottom",
|
"위아래": "Top and bottom",
|
||||||
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "Order of the top navigation buttons and of swiping. Change it with ▲ ▼.",
|
||||||
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.",
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.",
|
||||||
"유형": "Type",
|
"유형": "Type",
|
||||||
"음성": "Audio",
|
"음성": "Audio",
|
||||||
|
|
@ -1142,6 +1153,7 @@
|
||||||
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
|
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
|
||||||
"컴포넌트": "Components",
|
"컴포넌트": "Components",
|
||||||
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).",
|
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).",
|
||||||
|
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.",
|
||||||
"켜져 있는 창에서 (기본)": "In the open window (default)",
|
"켜져 있는 창에서 (기본)": "In the open window (default)",
|
||||||
"코덱": "Codec",
|
"코덱": "Codec",
|
||||||
"크기": "Size",
|
"크기": "Size",
|
||||||
|
|
@ -1274,6 +1286,7 @@
|
||||||
"확인": "OK",
|
"확인": "OK",
|
||||||
"확인 · 압축 푸는 중…": "Verifying · extracting…",
|
"확인 · 압축 푸는 중…": "Verifying · extracting…",
|
||||||
"확인 중…": "Checking…",
|
"확인 중…": "Checking…",
|
||||||
|
"확장 설치": "Installing extension",
|
||||||
"확장자": "Ext",
|
"확장자": "Ext",
|
||||||
"확장자별 재생 프로그램": "Player per file extension",
|
"확장자별 재생 프로그램": "Player per file extension",
|
||||||
"환경 설정": "Settings",
|
"환경 설정": "Settings",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@
|
||||||
"PC 전체 CPU · 메모리 사용량": "PC 全体の CPU・メモリ使用量",
|
"PC 전체 CPU · 메모리 사용량": "PC 全体の CPU・メモリ使用量",
|
||||||
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "PC 全体の使用量\nCPU {0}%\nメモリ {1} / {2}GB({3}%){4}",
|
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "PC 全体の使用量\nCPU {0}%\nメモリ {1} / {2}GB({3}%){4}",
|
||||||
"PDF 로 만들기": "PDF を作成",
|
"PDF 로 만들기": "PDF を作成",
|
||||||
|
"PDF 로 바꾸는 중: {0}": "PDF に変換中: {0}",
|
||||||
"PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}",
|
"PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}",
|
||||||
"PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}",
|
"PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}",
|
||||||
"PDF 에 넣을 그림 선택": "PDF に追加する画像を選択",
|
"PDF 에 넣을 그림 선택": "PDF に追加する画像を選択",
|
||||||
|
|
@ -143,6 +144,7 @@
|
||||||
"Whisper small (정확)": "Whisper small(高精度)",
|
"Whisper small (정확)": "Whisper small(高精度)",
|
||||||
"Windows 기본 브라우저": "Windows の既定のブラウザー",
|
"Windows 기본 브라우저": "Windows の既定のブラウザー",
|
||||||
"Windows 기본 프로그램": "Windows の既定のプログラム",
|
"Windows 기본 프로그램": "Windows の既定のプログラム",
|
||||||
|
"Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Windows でのみ使えます。この端末では文書を他のアプリで開きます。",
|
||||||
"Windows 탐색기": "Windows エクスプローラー",
|
"Windows 탐색기": "Windows エクスプローラー",
|
||||||
"YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧",
|
"YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧",
|
||||||
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)",
|
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)",
|
||||||
|
|
@ -201,14 +203,17 @@
|
||||||
"{0} 번역 중": "{0} を翻訳中",
|
"{0} 번역 중": "{0} を翻訳中",
|
||||||
"{0} 번역 중 {1}%": "{0} を翻訳中 {1}%",
|
"{0} 번역 중 {1}%": "{0} を翻訳中 {1}%",
|
||||||
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)",
|
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)",
|
||||||
|
"{0} 설치": "{0} のインストール",
|
||||||
"{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}",
|
"{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}",
|
||||||
"{0} 아래에 만듭니다": "{0} の下に作成します",
|
"{0} 아래에 만듭니다": "{0} の下に作成します",
|
||||||
"{0} 안의 파일": "{0} 内のファイル",
|
"{0} 안의 파일": "{0} 内のファイル",
|
||||||
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
|
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
|
||||||
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
||||||
"{0} 외 {1}개": "{0} ほか {1} 件",
|
"{0} 외 {1}개": "{0} ほか {1} 件",
|
||||||
|
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
|
||||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
|
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
|
||||||
"{0} 전체": "{0} 全体",
|
"{0} 전체": "{0} 全体",
|
||||||
|
"{0} 제거": "{0} の削除",
|
||||||
"{0} 추가": "{0} を追加",
|
"{0} 추가": "{0} を追加",
|
||||||
"{0} 파일": "{0} ファイル",
|
"{0} 파일": "{0} ファイル",
|
||||||
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)",
|
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)",
|
||||||
|
|
@ -344,6 +349,7 @@
|
||||||
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)",
|
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)",
|
||||||
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
|
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
|
||||||
"내려받은 rsync 지우기": "ダウンロードした rsync を削除",
|
"내려받은 rsync 지우기": "ダウンロードした rsync を削除",
|
||||||
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。",
|
||||||
"내보냈습니다: {0}": "書き出しました: {0}",
|
"내보냈습니다: {0}": "書き出しました: {0}",
|
||||||
"내역": "履歴",
|
"내역": "履歴",
|
||||||
"내역 (최근 폴더)": "履歴 (最近のフォルダー)",
|
"내역 (최근 폴더)": "履歴 (最近のフォルダー)",
|
||||||
|
|
@ -517,11 +523,13 @@
|
||||||
"목록 보기": "リストを見る",
|
"목록 보기": "リストを見る",
|
||||||
"목록에서 제거": "リストから削除",
|
"목록에서 제거": "リストから削除",
|
||||||
"목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)",
|
"목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)",
|
||||||
|
"목록을 받는 중": "一覧を取得中",
|
||||||
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
|
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
|
||||||
"몽골어": "モンゴル語",
|
"몽골어": "モンゴル語",
|
||||||
"무한": "無制限",
|
"무한": "無制限",
|
||||||
"문서 미리보기": "文書プレビュー",
|
"문서 미리보기": "文書プレビュー",
|
||||||
"문자셋 경고": "文字コードの警告",
|
"문자셋 경고": "文字コードの警告",
|
||||||
|
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
||||||
"미얀마어": "ミャンマー語",
|
"미얀마어": "ミャンマー語",
|
||||||
"미지정": "未指定",
|
"미지정": "未指定",
|
||||||
|
|
@ -574,6 +582,7 @@
|
||||||
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
|
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
|
||||||
"변환": "変換",
|
"변환": "変換",
|
||||||
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
|
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
|
||||||
|
"보기": "表示",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
||||||
"복사": "コピー",
|
"복사": "コピー",
|
||||||
|
|
@ -662,6 +671,7 @@
|
||||||
"설치하는 중": "インストール中",
|
"설치하는 중": "インストール中",
|
||||||
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)",
|
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)",
|
||||||
"설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}",
|
"설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}",
|
||||||
|
"설치하지 못했습니다: {0}": "インストールできませんでした: {0}",
|
||||||
"성공: {0}": "成功: {0}",
|
"성공: {0}": "成功: {0}",
|
||||||
"세로 4:5": "縦 4:5",
|
"세로 4:5": "縦 4:5",
|
||||||
"세로 9:16": "縦 9:16",
|
"세로 9:16": "縦 9:16",
|
||||||
|
|
@ -843,6 +853,7 @@
|
||||||
"위 폴더": "上のフォルダー",
|
"위 폴더": "上のフォルダー",
|
||||||
"위로": "上へ",
|
"위로": "上へ",
|
||||||
"위아래": "上下",
|
"위아래": "上下",
|
||||||
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。▲ ▼ で変更します。",
|
||||||
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。",
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。",
|
||||||
"유형": "種類",
|
"유형": "種類",
|
||||||
"음성": "音声",
|
"음성": "音声",
|
||||||
|
|
@ -1142,6 +1153,7 @@
|
||||||
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
|
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
|
||||||
"컴포넌트": "コンポーネント",
|
"컴포넌트": "コンポーネント",
|
||||||
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。",
|
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。",
|
||||||
|
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。",
|
||||||
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
|
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
|
||||||
"코덱": "コーデック",
|
"코덱": "コーデック",
|
||||||
"크기": "サイズ",
|
"크기": "サイズ",
|
||||||
|
|
@ -1274,6 +1286,7 @@
|
||||||
"확인": "OK",
|
"확인": "OK",
|
||||||
"확인 · 압축 푸는 중…": "確認・展開中…",
|
"확인 · 압축 푸는 중…": "確認・展開中…",
|
||||||
"확인 중…": "確認中…",
|
"확인 중…": "確認中…",
|
||||||
|
"확장 설치": "拡張機能のインストール",
|
||||||
"확장자": "拡張子",
|
"확장자": "拡張子",
|
||||||
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
||||||
"환경 설정": "環境設定",
|
"환경 설정": "環境設定",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@
|
||||||
"PC 전체 CPU · 메모리 사용량": "整机 CPU · 内存使用率",
|
"PC 전체 CPU · 메모리 사용량": "整机 CPU · 内存使用率",
|
||||||
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "整机使用率\nCPU {0}%\n内存 {1} / {2}GB({3}%){4}",
|
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "整机使用率\nCPU {0}%\n内存 {1} / {2}GB({3}%){4}",
|
||||||
"PDF 로 만들기": "制作 PDF",
|
"PDF 로 만들기": "制作 PDF",
|
||||||
|
"PDF 로 바꾸는 중: {0}": "正在转换为 PDF: {0}",
|
||||||
"PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}",
|
"PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}",
|
||||||
"PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}",
|
"PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}",
|
||||||
"PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片",
|
"PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片",
|
||||||
|
|
@ -143,6 +144,7 @@
|
||||||
"Whisper small (정확)": "Whisper small(准确)",
|
"Whisper small (정확)": "Whisper small(准确)",
|
||||||
"Windows 기본 브라우저": "Windows 默认浏览器",
|
"Windows 기본 브라우저": "Windows 默认浏览器",
|
||||||
"Windows 기본 프로그램": "Windows 默认程序",
|
"Windows 기본 프로그램": "Windows 默认程序",
|
||||||
|
"Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "仅限 Windows。在本设备上会用其他应用打开文档。",
|
||||||
"Windows 탐색기": "Windows 资源管理器",
|
"Windows 탐색기": "Windows 资源管理器",
|
||||||
"YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表",
|
"YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表",
|
||||||
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)",
|
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)",
|
||||||
|
|
@ -201,14 +203,17 @@
|
||||||
"{0} 번역 중": "正在翻译 {0}",
|
"{0} 번역 중": "正在翻译 {0}",
|
||||||
"{0} 번역 중 {1}%": "正在翻译 {0} {1}%",
|
"{0} 번역 중 {1}%": "正在翻译 {0} {1}%",
|
||||||
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)",
|
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)",
|
||||||
|
"{0} 설치": "安装 {0}",
|
||||||
"{0} 순으로 정렬{1}": "按{0}排序{1}",
|
"{0} 순으로 정렬{1}": "按{0}排序{1}",
|
||||||
"{0} 아래에 만듭니다": "在 {0} 下创建",
|
"{0} 아래에 만듭니다": "在 {0} 下创建",
|
||||||
"{0} 안의 파일": "{0} 中的文件",
|
"{0} 안의 파일": "{0} 中的文件",
|
||||||
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
|
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
|
||||||
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
||||||
"{0} 외 {1}개": "{0} 等 {1} 个",
|
"{0} 외 {1}개": "{0} 等 {1} 个",
|
||||||
|
"{0} 을(를) 설치했습니다.": "已安装 {0}。",
|
||||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
|
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
|
||||||
"{0} 전체": "{0} 全部",
|
"{0} 전체": "{0} 全部",
|
||||||
|
"{0} 제거": "移除 {0}",
|
||||||
"{0} 추가": "添加 {0}",
|
"{0} 추가": "添加 {0}",
|
||||||
"{0} 파일": "{0} 文件",
|
"{0} 파일": "{0} 文件",
|
||||||
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)",
|
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)",
|
||||||
|
|
@ -344,6 +349,7 @@
|
||||||
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)",
|
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)",
|
||||||
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
|
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
|
||||||
"내려받은 rsync 지우기": "删除已下载的 rsync",
|
"내려받은 rsync 지우기": "删除已下载的 rsync",
|
||||||
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。",
|
||||||
"내보냈습니다: {0}": "已导出: {0}",
|
"내보냈습니다: {0}": "已导出: {0}",
|
||||||
"내역": "历史",
|
"내역": "历史",
|
||||||
"내역 (최근 폴더)": "历史 (最近的文件夹)",
|
"내역 (최근 폴더)": "历史 (最近的文件夹)",
|
||||||
|
|
@ -517,11 +523,13 @@
|
||||||
"목록 보기": "查看列表",
|
"목록 보기": "查看列表",
|
||||||
"목록에서 제거": "从列表中移除",
|
"목록에서 제거": "从列表中移除",
|
||||||
"목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)",
|
"목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)",
|
||||||
|
"목록을 받는 중": "正在获取列表",
|
||||||
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
|
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
|
||||||
"몽골어": "蒙古语",
|
"몽골어": "蒙古语",
|
||||||
"무한": "无限制",
|
"무한": "无限制",
|
||||||
"문서 미리보기": "文档预览",
|
"문서 미리보기": "文档预览",
|
||||||
"문자셋 경고": "字符集警告",
|
"문자셋 경고": "字符集警告",
|
||||||
|
"미리 볼 수 없습니다: {0}": "无法预览: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
||||||
"미얀마어": "缅甸语",
|
"미얀마어": "缅甸语",
|
||||||
"미지정": "未指定",
|
"미지정": "未指定",
|
||||||
|
|
@ -574,6 +582,7 @@
|
||||||
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
|
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
|
||||||
"변환": "转换",
|
"변환": "转换",
|
||||||
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
|
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
|
||||||
|
"보기": "查看",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
||||||
"복사": "复制",
|
"복사": "复制",
|
||||||
|
|
@ -662,6 +671,7 @@
|
||||||
"설치하는 중": "正在安装",
|
"설치하는 중": "正在安装",
|
||||||
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)",
|
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)",
|
||||||
"설치하지 못했습니다.\n{0}": "未能安装。\n{0}",
|
"설치하지 못했습니다.\n{0}": "未能安装。\n{0}",
|
||||||
|
"설치하지 못했습니다: {0}": "无法安装: {0}",
|
||||||
"성공: {0}": "成功: {0}",
|
"성공: {0}": "成功: {0}",
|
||||||
"세로 4:5": "竖屏 4:5",
|
"세로 4:5": "竖屏 4:5",
|
||||||
"세로 9:16": "竖屏 9:16",
|
"세로 9:16": "竖屏 9:16",
|
||||||
|
|
@ -843,6 +853,7 @@
|
||||||
"위 폴더": "上级文件夹",
|
"위 폴더": "上级文件夹",
|
||||||
"위로": "向上",
|
"위로": "向上",
|
||||||
"위아래": "上下",
|
"위아래": "上下",
|
||||||
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "顶部导航按钮和左右滑动的顺序。用 ▲ ▼ 更改。",
|
||||||
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。",
|
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。",
|
||||||
"유형": "类型",
|
"유형": "类型",
|
||||||
"음성": "音频",
|
"음성": "音频",
|
||||||
|
|
@ -1142,6 +1153,7 @@
|
||||||
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
|
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
|
||||||
"컴포넌트": "组件",
|
"컴포넌트": "组件",
|
||||||
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。",
|
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。",
|
||||||
|
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。",
|
||||||
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
|
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
|
||||||
"코덱": "编解码器",
|
"코덱": "编解码器",
|
||||||
"크기": "大小",
|
"크기": "大小",
|
||||||
|
|
@ -1274,6 +1286,7 @@
|
||||||
"확인": "确定",
|
"확인": "确定",
|
||||||
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
|
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
|
||||||
"확인 중…": "正在检查…",
|
"확인 중…": "正在检查…",
|
||||||
|
"확장 설치": "安装扩展",
|
||||||
"확장자": "扩展名",
|
"확장자": "扩展名",
|
||||||
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
||||||
"환경 설정": "设置",
|
"환경 설정": "设置",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:archive/archive_io.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../core/app_update.dart' show updateRepo;
|
||||||
|
|
||||||
|
/// 컴포넌트 목록 (GitHub 릴리스 "components" 의 components.json) 주소
|
||||||
|
const componentsManifestUrl = 'https://github.com/$updateRepo/releases/download/components/components.json';
|
||||||
|
|
||||||
|
/// 컴포넌트를 설치할 때 받는 파일 하나
|
||||||
|
class ComponentFile {
|
||||||
|
final String name;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// 'msi' (Windows 설치 파일 → 관리 설치로 풀기) · 'zip' (풀기, 예: Java 런타임) · 'oxt' (LibreOffice 확장) · 'file' (그대로 둠)
|
||||||
|
final String kind;
|
||||||
|
final String? sha256;
|
||||||
|
final int size;
|
||||||
|
const ComponentFile({required this.name, required this.url, required this.kind, this.sha256, this.size = 0});
|
||||||
|
|
||||||
|
factory ComponentFile.fromJson(Map<String, dynamic> j) => ComponentFile(
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
url: j['url'] as String,
|
||||||
|
kind: j['kind'] as String? ?? 'file',
|
||||||
|
sha256: (j['sha256'] as String?)?.toLowerCase(),
|
||||||
|
size: (j['size'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// components.json: { "format": 1, "components": { "(id)": { "version": "...", "windows": [파일...], "android": [...] } } }
|
||||||
|
class ComponentManifest {
|
||||||
|
final Map<String, Map<String, dynamic>> components;
|
||||||
|
const ComponentManifest(this.components);
|
||||||
|
|
||||||
|
factory ComponentManifest.parse(String text) {
|
||||||
|
final j = jsonDecode(text) as Map<String, dynamic>;
|
||||||
|
final c = (j['components'] as Map<String, dynamic>? ?? const {});
|
||||||
|
return ComponentManifest({for (final e in c.entries) e.key: e.value as Map<String, dynamic>});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 기기 ([platform]) 에서 그 컴포넌트를 설치할 때 받을 파일들 (없으면 null = 이 기기에서는 못 씀)
|
||||||
|
List<ComponentFile>? files(String id, String platform) {
|
||||||
|
final list = components[id]?[platform] as List?;
|
||||||
|
return list == null ? null : [for (final x in list) ComponentFile.fromJson(x as Map<String, dynamic>)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String version(String id) => components[id]?['version'] as String? ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComponentException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const ComponentException(this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 컴포넌트 설치 (받기 · 확인 · 풀기) · 제거, 그리고 설치한 도구 쓰기 (문서 → PDF).
|
||||||
|
/// 앱 데이터 폴더의 components/(id) 아래에 둔다 (앱을 업데이트해도 지워지지 않게).
|
||||||
|
class ComponentStore {
|
||||||
|
final String dataDir;
|
||||||
|
final String manifestUrl;
|
||||||
|
final HttpClient _http = HttpClient()..userAgent = 'JJMKVMaker';
|
||||||
|
bool _cancel = false;
|
||||||
|
|
||||||
|
ComponentStore(this.dataDir, {this.manifestUrl = componentsManifestUrl});
|
||||||
|
|
||||||
|
static ComponentStore? _shared;
|
||||||
|
|
||||||
|
/// 앱의 컴포넌트 저장소 (앱 데이터 폴더). 시험에서는 [ComponentStore.shared] 를 바꿔 쓴다.
|
||||||
|
static ComponentStore get shared => _shared ??= ComponentStore(_dataDir ?? Directory.systemTemp.path);
|
||||||
|
static set shared(ComponentStore s) => _shared = s;
|
||||||
|
|
||||||
|
/// main 에서 정함 (앱 데이터 폴더)
|
||||||
|
static String? _dataDir;
|
||||||
|
static set dataDirectory(String d) => _dataDir = d;
|
||||||
|
|
||||||
|
static String get platform => Platform.isWindows
|
||||||
|
? 'windows'
|
||||||
|
: Platform.isAndroid
|
||||||
|
? 'android'
|
||||||
|
: 'other';
|
||||||
|
|
||||||
|
Directory dirOf(String id) => Directory(p.join(dataDir, 'components', id));
|
||||||
|
File _mark(String id) => File(p.join(dirOf(id).path, 'installed.json'));
|
||||||
|
|
||||||
|
bool isInstalled(String id) => _mark(id).existsSync();
|
||||||
|
|
||||||
|
void cancel() => _cancel = true;
|
||||||
|
|
||||||
|
Future<ComponentManifest> manifest() async {
|
||||||
|
final req = await _http.getUrl(Uri.parse(manifestUrl));
|
||||||
|
final res = await req.close();
|
||||||
|
final body = await res.transform(utf8.decoder).join();
|
||||||
|
if (res.statusCode != 200) throw ComponentException('컴포넌트 목록을 받을 수 없습니다 (${res.statusCode})');
|
||||||
|
return ComponentManifest.parse(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설치: 목록의 파일을 받아 (SHA-256 확인) 풀고 설치 표시를 남긴다.
|
||||||
|
/// [onProgress] (지금 하는 일, 0~1 또는 모르면 null)
|
||||||
|
Future<void> install(String id, {void Function(String step, double? progress)? onProgress}) async {
|
||||||
|
_cancel = false;
|
||||||
|
final m = await manifest();
|
||||||
|
final files = m.files(id, platform);
|
||||||
|
if (files == null) throw const ComponentException('이 기기에서는 쓸 수 없는 컴포넌트입니다');
|
||||||
|
final dir = dirOf(id);
|
||||||
|
final dl = Directory(p.join(dir.path, '_download'));
|
||||||
|
await dl.create(recursive: true);
|
||||||
|
try {
|
||||||
|
final got = <(ComponentFile, File)>[];
|
||||||
|
for (final f in files) {
|
||||||
|
final out = File(p.join(dl.path, f.name.isEmpty ? p.basename(Uri.parse(f.url).path) : f.name));
|
||||||
|
await _download(f, out, (d) => onProgress?.call('받는 중: ${f.name}', d));
|
||||||
|
got.add((f, out));
|
||||||
|
}
|
||||||
|
for (final (f, file) in got) {
|
||||||
|
if (_cancel) throw const ComponentException('취소했습니다');
|
||||||
|
switch (f.kind) {
|
||||||
|
case 'msi':
|
||||||
|
onProgress?.call('푸는 중: ${f.name}', null);
|
||||||
|
await _runMsiAdmin(file.path, p.join(dir.path, 'app'));
|
||||||
|
case 'zip':
|
||||||
|
onProgress?.call('푸는 중: ${f.name}', null);
|
||||||
|
await extractFileToDisk(file.path, p.join(dir.path, 'jre'));
|
||||||
|
case 'oxt':
|
||||||
|
onProgress?.call('확장 설치: ${f.name}', null);
|
||||||
|
await _addExtension(id, file.path);
|
||||||
|
default:
|
||||||
|
await file.copy(p.join(dir.path, p.basename(file.path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _mark(id).writeAsString(jsonEncode({'version': m.version(id), 'installed': DateTime.now().toIso8601String()}));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await dl.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String id) async {
|
||||||
|
final dir = dirOf(id);
|
||||||
|
if (await dir.exists()) await dir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _download(ComponentFile f, File out, void Function(double? progress) onProgress) async {
|
||||||
|
final req = await _http.getUrl(Uri.parse(f.url));
|
||||||
|
final res = await req.close();
|
||||||
|
if (res.statusCode != 200) throw ComponentException('받을 수 없습니다 (${res.statusCode}): ${f.url}');
|
||||||
|
final total = res.contentLength > 0 ? res.contentLength : f.size;
|
||||||
|
final sink = out.openWrite();
|
||||||
|
final hash = AccumulatorSink<Digest>();
|
||||||
|
final hasher = sha256.startChunkedConversion(hash);
|
||||||
|
var done = 0;
|
||||||
|
try {
|
||||||
|
await for (final chunk in res) {
|
||||||
|
if (_cancel) throw const ComponentException('취소했습니다');
|
||||||
|
sink.add(chunk);
|
||||||
|
hasher.add(chunk);
|
||||||
|
done += chunk.length;
|
||||||
|
onProgress(total > 0 ? done / total : null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await sink.close();
|
||||||
|
hasher.close();
|
||||||
|
}
|
||||||
|
final sum = hash.events.single.toString();
|
||||||
|
if (f.sha256 != null && f.sha256!.isNotEmpty && sum != f.sha256) {
|
||||||
|
await out.delete();
|
||||||
|
throw ComponentException('파일이 손상되었습니다 (SHA-256 다름): ${f.name}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows 설치 파일 (MSI) 을 관리 설치 (/a) 로 풀기: 관리자 권한 없이 폴더에 프로그램 파일만
|
||||||
|
Future<void> _runMsiAdmin(String msi, String target) async {
|
||||||
|
await Directory(target).create(recursive: true);
|
||||||
|
final r = await Process.run('msiexec', ['/a', msi, '/qn', 'TARGETDIR=$target']);
|
||||||
|
if (r.exitCode != 0) throw ComponentException('설치 파일을 풀 수 없습니다 (msiexec ${r.exitCode})');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── 문서 미리보기 (LibreOffice) ─────────
|
||||||
|
|
||||||
|
/// 설치한 LibreOffice 의 soffice.exe (없으면 null)
|
||||||
|
String? soffice([String id = 'docs']) {
|
||||||
|
final root = Directory(p.join(dirOf(id).path, 'app'));
|
||||||
|
if (!root.existsSync()) return null;
|
||||||
|
for (final e in root.listSync(recursive: true, followLinks: false)) {
|
||||||
|
if (e is File && p.basename(e.path).toLowerCase() == 'soffice.exe' && p.basename(p.dirname(e.path)) == 'program') {
|
||||||
|
return e.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 함께 받은 Java 런타임 (HWP 확장 H2Orestart 가 Java 로 동작) 의 폴더 (bin/java.exe 가 있는 곳)
|
||||||
|
String? javaHome([String id = 'docs']) {
|
||||||
|
final root = Directory(p.join(dirOf(id).path, 'jre'));
|
||||||
|
if (!root.existsSync()) return null;
|
||||||
|
for (final e in root.listSync(recursive: true, followLinks: false)) {
|
||||||
|
if (e is File && p.basename(e.path).toLowerCase() == 'java.exe' && p.basename(p.dirname(e.path)) == 'bin') {
|
||||||
|
return p.dirname(p.dirname(e.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LibreOffice 가 쓸 환경: 받은 Java (JAVA_HOME)
|
||||||
|
Map<String, String>? _env(String id) {
|
||||||
|
final jh = javaHome(id);
|
||||||
|
return jh == null ? null : {'JAVA_HOME': jh, 'PATH': '${p.join(jh, 'bin')};${Platform.environment['PATH'] ?? ''}'};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LibreOffice 사용자 설정 폴더 (앱 전용, 확장도 여기에)
|
||||||
|
String _profileUrl(String id) => Uri.directory(p.join(dirOf(id).path, 'profile')).toString();
|
||||||
|
|
||||||
|
Future<void> _addExtension(String id, String oxt) async {
|
||||||
|
final office = soffice(id);
|
||||||
|
if (office == null) throw const ComponentException('LibreOffice 를 먼저 설치해야 합니다');
|
||||||
|
final unopkg = p.join(p.dirname(office), 'unopkg.com');
|
||||||
|
final r = await Process.run(unopkg, ['add', '--suppress-license', '-env:UserInstallation=${_profileUrl(id)}', oxt],
|
||||||
|
environment: _env(id));
|
||||||
|
if (r.exitCode != 0) throw ComponentException('확장을 설치할 수 없습니다 (${r.exitCode}): ${r.stderr}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 문서를 PDF 로 ([outDir] 에 만든 PDF 경로)
|
||||||
|
Future<String> convertToPdf(String input, String outDir, {String id = 'docs'}) async {
|
||||||
|
final office = soffice(id);
|
||||||
|
if (office == null) throw const ComponentException('문서 미리보기 컴포넌트를 먼저 설치하세요');
|
||||||
|
await Directory(outDir).create(recursive: true);
|
||||||
|
final r = await Process.run(office, [
|
||||||
|
'--headless', '--norestore', '--nologo', '--nodefault',
|
||||||
|
'-env:UserInstallation=${_profileUrl(id)}',
|
||||||
|
'--convert-to', 'pdf', '--outdir', outDir, input,
|
||||||
|
], environment: _env(id)).timeout(const Duration(minutes: 3));
|
||||||
|
final out = p.join(outDir, '${p.basenameWithoutExtension(input)}.pdf');
|
||||||
|
if (!File(out).existsSync()) {
|
||||||
|
throw ComponentException('PDF 로 바꾸지 못했습니다 (${r.exitCode}) ${'${r.stderr}'.trim()}');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sha256 의 startChunkedConversion 결과를 받는 그릇
|
||||||
|
class AccumulatorSink<T> implements Sink<T> {
|
||||||
|
final events = <T>[];
|
||||||
|
@override
|
||||||
|
void add(T event) => events.add(event);
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,9 @@ class AppComponent {
|
||||||
|
|
||||||
static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs];
|
static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs];
|
||||||
|
|
||||||
|
/// 문서 미리보기 대상 (PDF 로 바꿔 봄)
|
||||||
|
static const docExtensions = ['doc', 'docx', 'hwp', 'hwpx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf'];
|
||||||
|
|
||||||
/// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼)
|
/// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼)
|
||||||
static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads'];
|
static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ List<ReleaseInfo> parseReleaseList(List<dynamic> list, {String assetPattern = wi
|
||||||
final out = [
|
final out = [
|
||||||
for (final j in list)
|
for (final j in list)
|
||||||
if (j is Map<String, dynamic>) parseRelease(j, assetPattern: assetPattern, allowPrerelease: true),
|
if (j is Map<String, dynamic>) parseRelease(j, assetPattern: assetPattern, allowPrerelease: true),
|
||||||
].whereType<ReleaseInfo>().toList();
|
// 이 기기용 설치 파일이 있는 버전 릴리스만 (컴포넌트 목록 릴리스 "components" 등은 빼고)
|
||||||
|
].whereType<ReleaseInfo>().where((r) => r.zipUrl != null && RegExp(r'^v?\d').hasMatch(r.tag)).toList();
|
||||||
out.sort((a, b) => compareVersions(b.version, a.version));
|
out.sort((a, b) => compareVersions(b.version, a.version));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:window_manager/window_manager.dart';
|
||||||
import 'app/app_controller.dart';
|
import 'app/app_controller.dart';
|
||||||
import 'app/live_sync.dart';
|
import 'app/live_sync.dart';
|
||||||
import 'app/version_snapshot.dart';
|
import 'app/version_snapshot.dart';
|
||||||
|
import 'app/component_store.dart';
|
||||||
import 'app/bookmarks_controller.dart';
|
import 'app/bookmarks_controller.dart';
|
||||||
import 'app/download_manager.dart';
|
import 'app/download_manager.dart';
|
||||||
import 'app/settings.dart';
|
import 'app/settings.dart';
|
||||||
|
|
@ -95,6 +96,7 @@ Future<void> main(List<String> args) async {
|
||||||
}
|
}
|
||||||
// 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치)
|
// 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치)
|
||||||
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
||||||
|
ComponentStore.dataDirectory = dataDir;
|
||||||
VersionSnapshot.instance = VersionSnapshot(dataDir);
|
VersionSnapshot.instance = VersionSnapshot(dataDir);
|
||||||
await controller.init();
|
await controller.init();
|
||||||
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
||||||
|
|
@ -396,6 +398,7 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
|
controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
|
||||||
// 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때)
|
// 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때)
|
||||||
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
|
||||||
|
ComponentStore.dataDirectory = dataDir;
|
||||||
String? sharedSnapshots;
|
String? sharedSnapshots;
|
||||||
try {
|
try {
|
||||||
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot');
|
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot');
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,10 @@ class AppNavButtons extends StatelessWidget {
|
||||||
class SwipeNav extends StatefulWidget {
|
class SwipeNav extends StatefulWidget {
|
||||||
final String current;
|
final String current;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const SwipeNav({super.key, required this.current, required this.child});
|
|
||||||
|
/// 높이 어디서 시작해도 (위쪽 막대처럼 작은 곳에 씌울 때)
|
||||||
|
final bool anywhere;
|
||||||
|
const SwipeNav({super.key, required this.current, required this.child, this.anywhere = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SwipeNav> createState() => _SwipeNavState();
|
State<SwipeNav> createState() => _SwipeNavState();
|
||||||
|
|
@ -258,7 +261,7 @@ class _SwipeNavState extends State<SwipeNav> {
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onHorizontalDragStart: (d) {
|
onHorizontalDragStart: (d) {
|
||||||
final h = context.size?.height ?? 0;
|
final h = context.size?.height ?? 0;
|
||||||
_middle = h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8;
|
_middle = widget.anywhere || (h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8);
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (d) {
|
onHorizontalDragEnd: (d) {
|
||||||
final v = d.primaryVelocity ?? 0;
|
final v = d.primaryVelocity ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -432,10 +432,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
child: Focus(
|
child: Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
// 웹 페이지 위에서의 밀기는 웹 페이지가 받으므로, 위쪽 막대 · 즐겨찾기 줄에서 좌우로 밀면 화면 이동
|
body: ListenableBuilder(
|
||||||
body: SwipeNav(
|
|
||||||
current: 'browser',
|
|
||||||
child: ListenableBuilder(
|
|
||||||
listenable: Listenable.merge([bm, widget.c]),
|
listenable: Listenable.merge([bm, widget.c]),
|
||||||
builder: (context, _) => Column(children: [
|
builder: (context, _) => Column(children: [
|
||||||
_toolbar(),
|
_toolbar(),
|
||||||
|
|
@ -505,14 +502,16 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
])),
|
])),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _toolbar() {
|
/// 위쪽 막대: 여기서 좌우로 밀면 화면 이동 (웹 페이지 위에서는 웹 페이지의 스크롤과 다투어 위 · 아래가 이상했음)
|
||||||
|
Widget _toolbar() => SwipeNav(current: 'browser', anywhere: true, child: _toolbarBar());
|
||||||
|
|
||||||
|
Widget _toolbarBar() {
|
||||||
final starred = bm.tree.findByUrl(_url) != null;
|
final starred = bm.tree.findByUrl(_url) != null;
|
||||||
IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton(
|
IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton(
|
||||||
tooltip: tip,
|
tooltip: tip,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
|
import '../app/component_store.dart';
|
||||||
import '../app/components.dart';
|
import '../app/components.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'setting_tile.dart';
|
import 'setting_tile.dart';
|
||||||
|
|
@ -48,6 +51,82 @@ class ViewerSettings extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 내려받아 설치하는 컴포넌트 켜기 (진행 창 · 취소) · 끄기 (지우기)
|
||||||
|
Future<void> _setDownloaded(BuildContext context, AppComponent x, bool on) async {
|
||||||
|
final c = (context.findAncestorWidgetOfExactType<ComponentSettings>())!.c;
|
||||||
|
final store = ComponentStore.shared;
|
||||||
|
if (!on) {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(trf('{0} 제거', [tr(x.name)])),
|
||||||
|
content: Text(tr('내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.')),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
await store.remove(x.id);
|
||||||
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final step = ValueNotifier<(String, double?)>((tr('목록을 받는 중'), null));
|
||||||
|
Object? error;
|
||||||
|
var done = false;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final dialog = showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(trf('{0} 설치', [tr(x.name)])),
|
||||||
|
content: ValueListenableBuilder<(String, double?)>(
|
||||||
|
valueListenable: step,
|
||||||
|
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(v.$1),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
LinearProgressIndicator(value: v.$2),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
store.cancel();
|
||||||
|
},
|
||||||
|
child: Text(tr('취소')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// 단계 글 ("받는 중: 파일") 의 앞부분만 번역
|
||||||
|
String trStep(String s) {
|
||||||
|
for (final k in ['받는 중', '푸는 중', '확장 설치']) {
|
||||||
|
if (s.startsWith('$k:')) return '${tr(k)}:${s.substring(k.length + 1)}';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.install(x.id, onProgress: (s, d) => step.value = (trStep(s), d));
|
||||||
|
done = true;
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
await dialog;
|
||||||
|
step.dispose();
|
||||||
|
if (done) {
|
||||||
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id, x.id]);
|
||||||
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(
|
||||||
|
content: Text(done ? trf('{0} 을(를) 설치했습니다.', [tr(x.name)]) : trf('설치하지 못했습니다: {0}', [error]))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동.
|
/// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동.
|
||||||
/// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다.
|
/// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다.
|
||||||
class ComponentSettings extends StatelessWidget {
|
class ComponentSettings extends StatelessWidget {
|
||||||
|
|
@ -61,6 +140,7 @@ class ComponentSettings extends StatelessWidget {
|
||||||
final pages = AppComponent.pages(installed, s.navOrder);
|
final pages = AppComponent.pages(installed, s.navOrder);
|
||||||
|
|
||||||
Future<void> setInstalled(AppComponent x, bool on) async {
|
Future<void> setInstalled(AppComponent x, bool on) async {
|
||||||
|
if (x.needsDownload) return _setDownloaded(context, x, on);
|
||||||
// 화면 있는 것이 하나도 남지 않게는 못 한다
|
// 화면 있는 것이 하나도 남지 않게는 못 한다
|
||||||
if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) {
|
if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) {
|
||||||
ScaffoldMessenger.maybeOf(context)
|
ScaffoldMessenger.maybeOf(context)
|
||||||
|
|
@ -73,6 +153,13 @@ class ComponentSettings extends StatelessWidget {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [from] 번째 화면을 [to] 자리로 (설치하지 않은 화면은 원래 순서대로 뒤에)
|
||||||
|
void move(int from, int to) {
|
||||||
|
final ids = [for (final p in pages) p.id];
|
||||||
|
ids.insert(to, ids.removeAt(from));
|
||||||
|
c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: s.swipeNav,
|
value: s.swipeNav,
|
||||||
|
|
@ -84,42 +171,39 @@ class ComponentSettings extends StatelessWidget {
|
||||||
SettingTile(
|
SettingTile(
|
||||||
leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim),
|
leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim),
|
||||||
title: Text(tr(x.name)),
|
title: Text(tr(x.name)),
|
||||||
subtitle: Text(x.needsDownload ? '${tr(x.description)}\n${tr('설치하면 필요한 파일을 내려받습니다 (준비 중)')}' : tr(x.description)),
|
subtitle: Text(x.needsDownload
|
||||||
|
? '${tr(x.description)}\n${Platform.isWindows ? tr('켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.') : tr('Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.')}'
|
||||||
|
: tr(x.description)),
|
||||||
trailing: Switch(
|
trailing: Switch(
|
||||||
value: installed.contains(x.id),
|
value: installed.contains(x.id),
|
||||||
onChanged: x.needsDownload && !installed.contains(x.id) ? null : (v) => setInstalled(x, v),
|
onChanged: x.needsDownload && !Platform.isWindows ? null : (v) => setInstalled(x, v),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
SettingTile(
|
SettingTile(
|
||||||
leading: const Icon(Icons.swap_vert),
|
leading: const Icon(Icons.swap_vert),
|
||||||
title: Text(tr('화면 순서')),
|
title: Text(tr('화면 순서')),
|
||||||
subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.')),
|
subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.')),
|
||||||
),
|
),
|
||||||
ReorderableListView(
|
for (final (i, p) in pages.indexed)
|
||||||
shrinkWrap: true,
|
ListTile(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
key: ValueKey(p.id),
|
||||||
buildDefaultDragHandles: false,
|
dense: true,
|
||||||
onReorderItem: (from, to) {
|
leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)),
|
||||||
final ids = [for (final p in pages) p.id];
|
title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Flexible(child: Text(tr(p.name)))]),
|
||||||
ids.insert(to, ids.removeAt(from));
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
// 설치하지 않은 화면은 원래 자리 순서대로 뒤에
|
IconButton(
|
||||||
c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]);
|
tooltip: tr('위로'),
|
||||||
},
|
icon: const Icon(Icons.arrow_upward, size: 18),
|
||||||
children: [
|
onPressed: i == 0 ? null : () => move(i, i - 1),
|
||||||
for (final (i, p) in pages.indexed)
|
|
||||||
ReorderableDragStartListener(
|
|
||||||
key: ValueKey(p.id),
|
|
||||||
index: i,
|
|
||||||
child: ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)),
|
|
||||||
title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Text(tr(p.name))]),
|
|
||||||
trailing: const Icon(Icons.drag_handle),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
IconButton(
|
||||||
),
|
tooltip: tr('아래로'),
|
||||||
|
icon: const Icon(Icons.arrow_downward, size: 18),
|
||||||
|
onPressed: i == pages.length - 1 ? null : () => move(i, i + 1),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import 'rsync_setup.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'webdav_settings.dart';
|
import 'webdav_settings.dart';
|
||||||
import 'reader_page.dart';
|
import 'reader_page.dart';
|
||||||
|
import '../app/component_store.dart';
|
||||||
|
import '../app/components.dart';
|
||||||
import 'zip_page.dart';
|
import 'zip_page.dart';
|
||||||
import '../core/reader_sources.dart';
|
import '../core/reader_sources.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
@ -548,6 +550,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
if (ext == 'pdf') return _viewPdf(path);
|
if (ext == 'pdf') return _viewPdf(path);
|
||||||
if (zipExtensions.contains(ext)) return _openZip(path, pane);
|
if (zipExtensions.contains(ext)) return _openZip(path, pane);
|
||||||
}
|
}
|
||||||
|
// 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
|
||||||
|
if (c.settings.components.contains('docs') && Platform.isWindows &&
|
||||||
|
AppComponent.docExtensions.contains(extOf(path))) {
|
||||||
|
return _viewDoc(path);
|
||||||
|
}
|
||||||
final local = await _fetch(path);
|
final local = await _fetch(path);
|
||||||
if (local == null) return;
|
if (local == null) return;
|
||||||
final ok = await c.services.shell.openWith(local);
|
final ok = await c.services.shell.openWith(local);
|
||||||
|
|
@ -623,6 +630,46 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
|
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 문서 → PDF (진행 창) → 보기
|
||||||
|
Future<void> _viewDoc(String path) async {
|
||||||
|
final temp = await c.services.storage.tempDirectory();
|
||||||
|
if (!mounted) return;
|
||||||
|
final dialog = showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
content: Row(children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Text(trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
String? pdf;
|
||||||
|
Object? error;
|
||||||
|
try {
|
||||||
|
final local = await vLocalCopy(path, temp);
|
||||||
|
pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs'));
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
await dialog;
|
||||||
|
if (pdf == null) {
|
||||||
|
_snack(trf('미리 볼 수 없습니다: {0}', [error]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PdfSource src;
|
||||||
|
try {
|
||||||
|
src = await PdfSource.open(pdf, tempDir: temp);
|
||||||
|
} catch (e) {
|
||||||
|
_snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
await openReader(context, c, src);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _viewPdf(String path) async {
|
Future<void> _viewPdf(String path) async {
|
||||||
final temp = await c.services.storage.tempDirectory();
|
final temp = await c.services.storage.tempDirectory();
|
||||||
PdfSource src;
|
PdfSource src;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/component_store.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// 실제 설치 (LibreOffice 373MB 받기 · 풀기 · H2Orestart · 문서 → PDF): JJ_COMPONENTS_URL=목록 주소 일 때만.
|
||||||
|
/// JJ_COMPONENTS_DIR 를 주면 그 폴더에 설치하고 남긴다 (여러 번 시험할 때 다시 받지 않게).
|
||||||
|
void main() {
|
||||||
|
test('components.json 읽기: 기기별 파일 · 없는 기기는 null', () {
|
||||||
|
final m = ComponentManifest.parse('''
|
||||||
|
{"format":1,"components":{"docs":{"version":"x","windows":[{"name":"a.msi","kind":"msi","url":"https://e/a.msi","sha256":"AB"}]}}}''');
|
||||||
|
expect(m.version('docs'), 'x');
|
||||||
|
final f = m.files('docs', 'windows')!.single;
|
||||||
|
expect([f.name, f.kind, f.sha256], ['a.msi', 'msi', 'ab']);
|
||||||
|
expect(m.files('docs', 'android'), isNull);
|
||||||
|
expect(m.files('nope', 'windows'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
final url = Platform.environment['JJ_COMPONENTS_URL'];
|
||||||
|
test('실제 설치: LibreOffice + H2Orestart → DOCX · HWPX 를 PDF 로', () async {
|
||||||
|
final keep = Platform.environment['JJ_COMPONENTS_DIR'];
|
||||||
|
final data = keep ?? Directory.systemTemp.createTempSync('jj_comp_').path;
|
||||||
|
final store = ComponentStore(data, manifestUrl: url!);
|
||||||
|
if (!store.isInstalled('docs')) {
|
||||||
|
var last = '';
|
||||||
|
await store.install('docs', onProgress: (step, d) {
|
||||||
|
final s = '$step ${d == null ? '' : (d * 100).floor()}';
|
||||||
|
if (s != last && (d == null || (d * 100).floor() % 10 == 0)) debugPrint(s);
|
||||||
|
last = s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(store.isInstalled('docs'), isTrue);
|
||||||
|
expect(store.soffice(), isNotNull);
|
||||||
|
// 시험 문서: LibreOffice 로 텍스트 → DOCX 를 만든 뒤 다시 PDF 로
|
||||||
|
final tmp = Directory.systemTemp.createTempSync('jj_doc_');
|
||||||
|
final txt = File(p.join(tmp.path, 'hello.txt'))..writeAsStringSync('안녕하세요 JJ 문서 미리보기');
|
||||||
|
final pdf = await store.convertToPdf(txt.path, p.join(tmp.path, 'out'));
|
||||||
|
expect(File(pdf).lengthSync(), greaterThan(1000));
|
||||||
|
final hwp = Platform.environment['JJ_HWP_FILE'];
|
||||||
|
if (hwp != null) {
|
||||||
|
final out = await store.convertToPdf(hwp, p.join(tmp.path, 'out'));
|
||||||
|
expect(File(out).lengthSync(), greaterThan(1000));
|
||||||
|
}
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
if (keep == null) await store.remove('docs');
|
||||||
|
}, skip: url == null ? 'JJ_COMPONENTS_URL 일 때만' : null, timeout: const Timeout(Duration(minutes: 30)));
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
import 'package:jj_mkvmaker/ui/app_actions.dart';
|
import 'package:jj_mkvmaker/ui/app_actions.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/component_settings.dart';
|
||||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||||
|
|
||||||
AppController _plain() =>
|
AppController _plain() =>
|
||||||
|
|
@ -54,6 +55,30 @@ void main() {
|
||||||
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
|
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('화면 순서: ▲ ▼ 로 바꾸고 위쪽 버튼 줄도 그 순서', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final c = _plain();
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
builder: (context, child) => AppScope(controller: c, child: child!),
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: ListenableBuilder(
|
||||||
|
listenable: c, builder: (_, _) => ComponentSettings(c: c)))),
|
||||||
|
));
|
||||||
|
// 2번째 (웹 브라우저) 를 위로 → 맨 앞
|
||||||
|
await tester.tap(find.byTooltip('위로').at(1));
|
||||||
|
await tester.pump();
|
||||||
|
expect(c.settings.navOrder.take(2), ['browser', 'mkv']);
|
||||||
|
// 맨 앞 (웹 브라우저) 를 아래로 → 다시 2번째
|
||||||
|
await tester.tap(find.byTooltip('아래로').first);
|
||||||
|
await tester.pump();
|
||||||
|
expect(c.settings.navOrder.take(2), ['mkv', 'browser']);
|
||||||
|
// 마지막 (다운로드) 를 위로 → 4번째
|
||||||
|
await tester.tap(find.byTooltip('위로').last);
|
||||||
|
await tester.pump();
|
||||||
|
expect(c.settings.navOrder, ['mkv', 'browser', 'explorer', 'downloads', 'rsync']);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async {
|
testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async {
|
||||||
tester.view.physicalSize = const Size(1500, 900);
|
tester.view.physicalSize = const Size(1500, 900);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue