WIP 다음 판: 199 권한 앱 목록 안내 · 188 버튼 글 맞춤 · 206 이동 버튼 · 181 · 182 mp3 재생 · 184 직접 파일 일반 받기 · 185 [계속 받기] (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음6
parent
3327d9bf9d
commit
6a782b2fdb
|
|
@ -581,6 +581,7 @@
|
||||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
|
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
|
||||||
"계속": "Always",
|
"계속": "Always",
|
||||||
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
|
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
|
||||||
|
"계속 받기": "Keep downloading",
|
||||||
"계속 보기 ({0}초마다 다음 장)": "Slideshow (next page every {0} s)",
|
"계속 보기 ({0}초마다 다음 장)": "Slideshow (next page every {0} s)",
|
||||||
"계속 보기 간격": "Slideshow interval",
|
"계속 보기 간격": "Slideshow interval",
|
||||||
"계속 보기 간격 늘리기 (지금 {0}초)": "Longer slideshow interval (now {0} s)",
|
"계속 보기 간격 늘리기 (지금 {0}초)": "Longer slideshow interval (now {0} s)",
|
||||||
|
|
@ -648,6 +649,7 @@
|
||||||
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)",
|
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).",
|
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
|
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
|
||||||
|
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
|
||||||
"기능 버튼 줄": "Button bar",
|
"기능 버튼 줄": "Button bar",
|
||||||
"기본": "Default",
|
"기본": "Default",
|
||||||
"기본 DNS": "Primary DNS",
|
"기본 DNS": "Primary DNS",
|
||||||
|
|
@ -782,6 +784,7 @@
|
||||||
"다시 시도": "Try again",
|
"다시 시도": "Try again",
|
||||||
"다시 시작": "Restart",
|
"다시 시작": "Restart",
|
||||||
"다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.",
|
"다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.",
|
||||||
|
"다시 열기": "Open again",
|
||||||
"다시 켤 때 동기화": "Sync on restart",
|
"다시 켤 때 동기화": "Sync on restart",
|
||||||
"다시 확인": "Check again",
|
"다시 확인": "Check again",
|
||||||
"다운로드": "Downloads",
|
"다운로드": "Downloads",
|
||||||
|
|
@ -1375,6 +1378,7 @@
|
||||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])",
|
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])",
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
||||||
|
"아직 허용되지 않았습니다": "Not allowed yet",
|
||||||
"안내 닫기": "Close tip",
|
"안내 닫기": "Close tip",
|
||||||
"안쪽 파일을 확인하는 중 …": "Checking files inside …",
|
"안쪽 파일을 확인하는 중 …": "Checking files inside …",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||||
|
|
@ -1388,6 +1392,7 @@
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
|
||||||
|
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.",
|
||||||
"앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
|
"앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
|
||||||
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)",
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)",
|
||||||
"앱 아이콘": "App icon",
|
"앱 아이콘": "App icon",
|
||||||
|
|
|
||||||
|
|
@ -581,6 +581,7 @@
|
||||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
|
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
|
||||||
"계속": "常時",
|
"계속": "常時",
|
||||||
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
|
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
|
||||||
|
"계속 받기": "ダウンロードを続ける",
|
||||||
"계속 보기 ({0}초마다 다음 장)": "連続表示 ({0} 秒ごとに次のページ)",
|
"계속 보기 ({0}초마다 다음 장)": "連続表示 ({0} 秒ごとに次のページ)",
|
||||||
"계속 보기 간격": "連続表示の間隔",
|
"계속 보기 간격": "連続表示の間隔",
|
||||||
"계속 보기 간격 늘리기 (지금 {0}초)": "連続表示の間隔を長く (現在 {0} 秒)",
|
"계속 보기 간격 늘리기 (지금 {0}초)": "連続表示の間隔を長く (現在 {0} 秒)",
|
||||||
|
|
@ -648,6 +649,7 @@
|
||||||
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)",
|
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。",
|
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
|
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
|
||||||
|
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
|
||||||
"기능 버튼 줄": "機能ボタンの列",
|
"기능 버튼 줄": "機能ボタンの列",
|
||||||
"기본": "既定",
|
"기본": "既定",
|
||||||
"기본 DNS": "優先 DNS",
|
"기본 DNS": "優先 DNS",
|
||||||
|
|
@ -782,6 +784,7 @@
|
||||||
"다시 시도": "再試行",
|
"다시 시도": "再試行",
|
||||||
"다시 시작": "再起動",
|
"다시 시작": "再起動",
|
||||||
"다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。",
|
"다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。",
|
||||||
|
"다시 열기": "もう一度開く",
|
||||||
"다시 켤 때 동기화": "再起動時の同期",
|
"다시 켤 때 동기화": "再起動時の同期",
|
||||||
"다시 확인": "再確認",
|
"다시 확인": "再確認",
|
||||||
"다운로드": "ダウンロード",
|
"다운로드": "ダウンロード",
|
||||||
|
|
@ -1375,6 +1378,7 @@
|
||||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
|
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
||||||
|
"아직 허용되지 않았습니다": "まだ許可されていません",
|
||||||
"안내 닫기": "案内を閉じる",
|
"안내 닫기": "案内を閉じる",
|
||||||
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
|
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||||
|
|
@ -1388,6 +1392,7 @@
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
||||||
|
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。",
|
||||||
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
|
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
|
||||||
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)",
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)",
|
||||||
"앱 아이콘": "アプリアイコン",
|
"앱 아이콘": "アプリアイコン",
|
||||||
|
|
|
||||||
|
|
@ -581,6 +581,7 @@
|
||||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
|
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
|
||||||
"계속": "持续",
|
"계속": "持续",
|
||||||
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
|
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
|
||||||
|
"계속 받기": "继续下载",
|
||||||
"계속 보기 ({0}초마다 다음 장)": "连续浏览 (每 {0} 秒下一页)",
|
"계속 보기 ({0}초마다 다음 장)": "连续浏览 (每 {0} 秒下一页)",
|
||||||
"계속 보기 간격": "连续浏览间隔",
|
"계속 보기 간격": "连续浏览间隔",
|
||||||
"계속 보기 간격 늘리기 (지금 {0}초)": "延长连续浏览间隔 (当前 {0} 秒)",
|
"계속 보기 간격 늘리기 (지금 {0}초)": "延长连续浏览间隔 (当前 {0} 秒)",
|
||||||
|
|
@ -648,6 +649,7 @@
|
||||||
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)",
|
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。",
|
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。",
|
||||||
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
|
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
|
||||||
|
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
|
||||||
"기능 버튼 줄": "功能按钮栏",
|
"기능 버튼 줄": "功能按钮栏",
|
||||||
"기본": "默认",
|
"기본": "默认",
|
||||||
"기본 DNS": "首选 DNS",
|
"기본 DNS": "首选 DNS",
|
||||||
|
|
@ -782,6 +784,7 @@
|
||||||
"다시 시도": "重试",
|
"다시 시도": "重试",
|
||||||
"다시 시작": "重新启动",
|
"다시 시작": "重新启动",
|
||||||
"다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。",
|
"다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。",
|
||||||
|
"다시 열기": "再次打开",
|
||||||
"다시 켤 때 동기화": "重新启动时同步",
|
"다시 켤 때 동기화": "重新启动时同步",
|
||||||
"다시 확인": "重新检查",
|
"다시 확인": "重新检查",
|
||||||
"다운로드": "下载",
|
"다운로드": "下载",
|
||||||
|
|
@ -1375,6 +1378,7 @@
|
||||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
|
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
||||||
|
"아직 허용되지 않았습니다": "尚未允许",
|
||||||
"안내 닫기": "关闭提示",
|
"안내 닫기": "关闭提示",
|
||||||
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
|
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||||
|
|
@ -1388,6 +1392,7 @@
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
||||||
|
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。",
|
||||||
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
|
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
|
||||||
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)",
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)",
|
||||||
"앱 아이콘": "应用图标",
|
"앱 아이콘": "应用图标",
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,15 @@ class DownloadManager extends ChangeNotifier {
|
||||||
if (detectLinks(url).isNotEmpty) return add(url);
|
if (detectLinks(url).isNotEmpty) return add(url);
|
||||||
if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null;
|
if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null;
|
||||||
if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null;
|
if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null;
|
||||||
|
// 184: 동영상이 아닌 직접 파일 주소 (zip 등) 는 일반 파일 받기 (aria2) 로 - 원래 이름 그대로
|
||||||
|
if (isDirectFileUrl(url) && backends[DownloadKind.torrent] != null) {
|
||||||
|
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.torrent, source: url.trim(), dir: settings().aria2Dir)
|
||||||
|
..title = Uri.tryParse(url.trim())?.pathSegments.lastOrNull ?? url;
|
||||||
|
tasks.insert(0, t);
|
||||||
|
_added.add(t);
|
||||||
|
_changed();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
if (backends[DownloadKind.video] == null) return null;
|
if (backends[DownloadKind.video] == null) return null;
|
||||||
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir);
|
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir);
|
||||||
tasks.insert(0, t);
|
tasks.insert(0, t);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,21 @@ enum DownloadKind {
|
||||||
/// YouTube 등 동영상 사이트 → yt-dlp
|
/// YouTube 등 동영상 사이트 → yt-dlp
|
||||||
video,
|
video,
|
||||||
|
|
||||||
/// 마그넷 링크 · .torrent 주소 → aria2
|
/// 마그넷 링크 · .torrent 주소 · 직접 파일 주소 (184: zip 등 - 원래 이름 그대로) → aria2
|
||||||
torrent,
|
torrent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 184: 동영상 · 소리가 아닌 직접 파일 주소 (…/100MB.zip 등). 동영상 받기 (yt-dlp) 로 받으면 다 받은 뒤
|
||||||
|
/// 후처리에서 "실패" 가 되고 이름도 "100MB [100MB].unknown_video" 가 됐다 - 일반 파일 받기 (aria2) 로 원래 이름 그대로 받는다.
|
||||||
|
/// 클립보드 자동 감지에는 쓰지 않는다 (복사만 해도 받기 시작하지 않게) - 사용자가 넣은 주소 · 브라우저 [다운로드] 에만
|
||||||
|
bool isDirectFileUrl(String url) => _directFile.hasMatch(url.trim());
|
||||||
|
|
||||||
|
final _directFile = RegExp(
|
||||||
|
r'^https?://[^\s<>"?#]+\.(?:zip|7z|rar|gz|tgz|tar|xz|bz2|zst|exe|msi|dmg|pkg|deb|rpm|appimage|iso|img|apk|apks|xapk|'
|
||||||
|
r'pdf|epub|docx?|xlsx?|pptx?|hwpx?|odt|ods|txt|csv|json|xml|bin|dat|ttf|otf|jpg|jpeg|png|gif|webp|bmp|svg|psd)'
|
||||||
|
r'(?:[?#][^\s<>"]*)?$',
|
||||||
|
caseSensitive: false);
|
||||||
|
|
||||||
class DetectedLink {
|
class DetectedLink {
|
||||||
final DownloadKind kind;
|
final DownloadKind kind;
|
||||||
final String url;
|
final String url;
|
||||||
|
|
|
||||||
|
|
@ -676,7 +676,7 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
// 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume)
|
// 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume)
|
||||||
final ctxA = navigatorKey.currentContext;
|
final ctxA = navigatorKey.currentContext;
|
||||||
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
|
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
|
||||||
await showAllFilesGuide(ctxA, AndroidAccess.request);
|
await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles);
|
||||||
}
|
}
|
||||||
// 124: "처음 시작 시" 면 마스터 비밀번호
|
// 124: "처음 시작 시" 면 마스터 비밀번호
|
||||||
final ctxM = navigatorKey.currentContext;
|
final ctxM = navigatorKey.currentContext;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
|
|
@ -17,7 +19,9 @@ Future<bool> allFilesGuideDue(AppController c, {required bool hasAccess}) async
|
||||||
|
|
||||||
/// 116: 처음 켤 때 "모든 파일에 대한 접근" 안내 (분석 오류보다 먼저). [허용] 은 Android 설정 화면을 연다.
|
/// 116: 처음 켤 때 "모든 파일에 대한 접근" 안내 (분석 오류보다 먼저). [허용] 은 Android 설정 화면을 연다.
|
||||||
/// 허용하고 돌아오면 앱이 실패한 분석을 저절로 다시 한다.
|
/// 허용하고 돌아오면 앱이 실패한 분석을 저절로 다시 한다.
|
||||||
Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() request) async {
|
/// 199: 기기에 따라 (노트 10+ · 삼성 Android 12) 이 앱 화면이 아니라 전체 앱 목록이 열린다 - 예외도 나지 않아 미리 알 수 없다.
|
||||||
|
/// 그래서 돌아왔을 때 [hasAccess] 가 아직 없으면 "목록에서 JJ_MKVMaker 를 찾아 켜세요" 와 [다시 열기] 를 보인다
|
||||||
|
Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() request, {Future<bool> Function()? hasAccess}) async {
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -26,7 +30,8 @@ Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() req
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 480,
|
width: 480,
|
||||||
child: Text('${tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')}\n\n'
|
child: Text('${tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')}\n\n'
|
||||||
'${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}'),
|
'${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}\n\n'
|
||||||
|
'${tr('기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.')}'),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
|
||||||
|
|
@ -34,5 +39,40 @@ Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() req
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (ok == true) await request();
|
if (ok != true) return;
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
final back = _resumed();
|
||||||
|
await request();
|
||||||
|
if (hasAccess == null) return;
|
||||||
|
await back;
|
||||||
|
if (await hasAccess() || !context.mounted) return;
|
||||||
|
// 199: 돌아왔는데 아직 꺼져 있음 - 앱 목록이 열렸거나 켜지 않고 돌아옴
|
||||||
|
final again = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(tr('아직 허용되지 않았습니다')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: Text(tr('앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 "모든 파일에 대한 접근" 을 켜고 돌아오세요.')),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다시 열기'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (again != true) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 다른 화면 (Android 설정) 에서 이 앱으로 돌아올 때까지
|
||||||
|
Future<void> _resumed() {
|
||||||
|
final done = Completer<void>();
|
||||||
|
late final AppLifecycleListener l;
|
||||||
|
l = AppLifecycleListener(onResume: () {
|
||||||
|
l.dispose();
|
||||||
|
if (!done.isCompleted) done.complete();
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../services/media_player.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// 182: 소리만 있는 파일 (mp3 등) 을 틀 때 영상 자리에 보일 것 - 예전에는 검은 화면뿐이었다.
|
||||||
|
/// 제목 (파일 이름) · 앨범 그림 (파일 안에 있으면) · 재생 위치
|
||||||
|
class AudioNowPlaying extends StatelessWidget {
|
||||||
|
final String file;
|
||||||
|
final ValueListenable<PlayerState> state;
|
||||||
|
|
||||||
|
/// 앨범 그림 파일 (없으면 null) - 파일마다 한 번만 꺼낸다
|
||||||
|
final Future<String?> cover;
|
||||||
|
const AudioNowPlaying({super.key, required this.file, required this.state, required this.cover});
|
||||||
|
|
||||||
|
static String _t(Duration d) {
|
||||||
|
final h = d.inHours, m = d.inMinutes % 60, s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
color: Colors.black,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Flexible(
|
||||||
|
child: FutureBuilder<String?>(
|
||||||
|
future: cover,
|
||||||
|
builder: (_, s) => ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||||
|
child: s.data != null
|
||||||
|
? Image.file(File(s.data!), fit: BoxFit.contain, errorBuilder: (_, _, _) => const _Note())
|
||||||
|
: const _Note(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(p.basenameWithoutExtension(file),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ValueListenableBuilder<PlayerState>(
|
||||||
|
valueListenable: state,
|
||||||
|
builder: (_, s, _) => Text(
|
||||||
|
'${_t(s.position)} / ${_t(s.duration)}',
|
||||||
|
style: const TextStyle(color: JjColors.textDim, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Note extends StatelessWidget {
|
||||||
|
const _Note();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: Color(0xFF1E1E1E), borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
child: Center(child: Icon(Icons.music_note, size: 96, color: JjColors.textDim)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,15 @@ import 'package:flutter/material.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true
|
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true.
|
||||||
|
/// [cancel]: 하지 않는 쪽 버튼 이름 (처음 값 "취소" - 확인 버튼이 "… 취소" 면 헷갈리므로 바꾼다, 185)
|
||||||
Future<bool> confirmAction(
|
Future<bool> confirmAction(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
required String ok,
|
required String ok,
|
||||||
bool danger = true,
|
bool danger = true,
|
||||||
|
String? cancel,
|
||||||
}) async {
|
}) async {
|
||||||
final r = await showDialog<bool>(
|
final r = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -18,7 +20,7 @@ Future<bool> confirmAction(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
content: Text(body),
|
content: Text(body),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(cancel ?? tr('취소'))),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: danger ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
|
style: danger ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,8 @@ class _DownloadsPageState extends State<DownloadsPage> {
|
||||||
title: tr('다운로드 취소'),
|
title: tr('다운로드 취소'),
|
||||||
body: trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [n]),
|
body: trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [n]),
|
||||||
ok: tr('다운로드 취소'),
|
ok: tr('다운로드 취소'),
|
||||||
|
// 185: 바로 옆 [다운로드 취소] 와 헷갈리지 않게 - 취소하지 않는 쪽은 [계속 받기]
|
||||||
|
cancel: tr('계속 받기'),
|
||||||
);
|
);
|
||||||
if (ok) await widget.d.cancelSelected();
|
if (ok) await widget.d.cancelSelected();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1405,7 +1405,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
await _reloadOpen();
|
await _reloadOpen();
|
||||||
case ExplorerButton.play:
|
case ExplorerButton.play:
|
||||||
final t = _targets(pane);
|
final t = _targets(pane);
|
||||||
final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(x)).toList();
|
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
|
||||||
|
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
|
||||||
if (videos.isNotEmpty) {
|
if (videos.isNotEmpty) {
|
||||||
await _play(videos, keepOrder: pane.marked.isNotEmpty);
|
await _play(videos, keepOrder: pane.marked.isNotEmpty);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3650,11 +3651,20 @@ class _Toolbar extends StatelessWidget {
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null),
|
Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(tr(_label(b)),
|
// 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게
|
||||||
maxLines: 2,
|
Flexible(
|
||||||
textAlign: TextAlign.center,
|
child: FittedBox(
|
||||||
overflow: TextOverflow.ellipsis,
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)),
|
child: SizedBox(
|
||||||
|
width: 68,
|
||||||
|
child: Text(tr(_label(b)),
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1014,8 +1014,18 @@ class _VideoDetail extends StatelessWidget {
|
||||||
label: Text('${t?.name ?? tr('이동')}$count'),
|
label: Text('${t?.name ?? tr('이동')}$count'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다
|
||||||
|
if (isCompact(context)) {
|
||||||
|
final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [
|
||||||
|
if (buttons.isEmpty) button(null),
|
||||||
|
for (final t in buttons) button(t),
|
||||||
|
]);
|
||||||
|
return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24);
|
||||||
|
}
|
||||||
|
// 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼)
|
||||||
|
final stacked = buttons.isEmpty ? 1 : buttons.length;
|
||||||
return Stack(children: [
|
return Stack(children: [
|
||||||
_list(context, info, video, audioCount, locked),
|
_list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
|
|
@ -1035,10 +1045,11 @@ class _VideoDetail extends StatelessWidget {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) {
|
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked,
|
||||||
|
{Widget? moveButtons, double bottom = 88}) {
|
||||||
final side = isCompact(context) ? 12.0 : 20.0;
|
final side = isCompact(context) ? 12.0 : 20.0;
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: EdgeInsets.fromLTRB(side, side, side, 88),
|
padding: EdgeInsets.fromLTRB(side, side, side, bottom),
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -1072,6 +1083,10 @@ class _VideoDetail extends StatelessWidget {
|
||||||
],
|
],
|
||||||
_Chip(trf('음성 {0}개', [audioCount])),
|
_Chip(trf('음성 {0}개', [audioCount])),
|
||||||
]),
|
]),
|
||||||
|
if (moveButtons != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
moveButtons,
|
||||||
|
],
|
||||||
// 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게
|
// 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게
|
||||||
if (v.aiDetected != null) ...[
|
if (v.aiDetected != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../core/playlist.dart';
|
||||||
import '../core/srt.dart' show formatSrtTime;
|
import '../core/srt.dart' show formatSrtTime;
|
||||||
import '../services/media_player.dart';
|
import '../services/media_player.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
|
import 'audio_now_playing.dart';
|
||||||
import 'dav_external.dart';
|
import 'dav_external.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
@ -92,6 +93,12 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||||
if (_popup) c.services.shell.setPopup(false);
|
if (_popup) c.services.shell.setPopup(false);
|
||||||
pl.dispose();
|
pl.dispose();
|
||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
|
// 182: 꺼낸 앨범 그림은 닫을 때 지운다 (임시 폴더에 쌓이지 않게)
|
||||||
|
for (final f in _covers.values) {
|
||||||
|
unawaited(f.then((path) async {
|
||||||
|
if (path != null) await File(path).delete();
|
||||||
|
}).catchError((_) {}));
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +320,9 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||||
onLongPressStart: (d) => _menu.open(position: d.localPosition),
|
onLongPressStart: (d) => _menu.open(position: d.localPosition),
|
||||||
child: Stack(fit: StackFit.expand, children: [
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
view,
|
view,
|
||||||
|
// 182: 소리만 있는 파일은 검은 화면 대신 제목 · 앨범 그림 · 재생 위치
|
||||||
|
if (isAudioFile(_currentFile))
|
||||||
|
IgnorePointer(child: AudioNowPlaying(file: _currentFile, state: pl.state, cover: _cover(_currentFile))),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|
@ -342,6 +352,23 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 182: 소리 파일 안의 앨범 그림을 꺼낸다 (FFmpeg, 파일마다 한 번). 없거나 못 꺼내면 null - 음표를 보인다.
|
||||||
|
/// WebDAV 는 받지 않고 틀므로 꺼내지 않는다
|
||||||
|
final _covers = <String, Future<String?>>{};
|
||||||
|
Future<String?> _cover(String file) => _covers[file] ??= () async {
|
||||||
|
if (file.startsWith('dav://') || file.startsWith('http')) return null;
|
||||||
|
try {
|
||||||
|
final dir = await c.services.storage.tempDirectory();
|
||||||
|
final out = p.join(dir, 'jj_cover_${file.hashCode.toUnsigned(32)}.jpg');
|
||||||
|
final f = File(out);
|
||||||
|
if (await f.exists()) await f.delete();
|
||||||
|
await c.services.mediaTool.runFfmpeg(['-y', '-v', 'error', '-i', file, '-an', '-frames:v', '1', out]);
|
||||||
|
return await f.exists() && await f.length() > 0 ? out : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
List<Widget> _contextMenu() {
|
List<Widget> _contextMenu() {
|
||||||
final s = pl.state.value;
|
final s = pl.state.value;
|
||||||
final subs = _subtitleChoices();
|
final subs = _subtitleChoices();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/playlist.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/media_player.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/audio_now_playing.dart';
|
||||||
|
|
||||||
|
/// 181 · 182: 소리 파일 (mp3) 재생
|
||||||
|
void main() {
|
||||||
|
test('181: mp3 는 소리 파일 - 탐색기 [재생] 이 강조한 mp3 를 튼다 (동영상이 아니라고 빼지 않음)', () {
|
||||||
|
expect([isAudioFile(r'D:\m\song.mp3'), isVideoFile(r'D:\m\song.mp3')], [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('182: 소리 파일은 검은 화면 대신 제목 · 앨범 그림 (없으면 음표) · 재생 위치', (tester) async {
|
||||||
|
final state = ValueNotifier(const PlayerState(position: Duration(seconds: 83), duration: Duration(minutes: 4, seconds: 56)));
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: AudioNowPlaying(file: r'D:\m\내 노래.mp3', state: state, cover: Future.value(null))),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('내 노래'), findsOneWidget);
|
||||||
|
expect(find.text('1:23 / 4:56'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.music_note), findsOneWidget);
|
||||||
|
state.value = const PlayerState(position: Duration(seconds: 84), duration: Duration(minutes: 4, seconds: 56));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('1:24 / 4:56'), findsOneWidget, reason: '재생 위치가 따라 바뀐다');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -394,6 +394,39 @@ void main() {
|
||||||
expect(find.textContaining('감지한 언어'), findsNothing);
|
expect(find.textContaining('감지한 언어'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (final (name, size, targets) in [
|
||||||
|
('폰 세로', const Size(412, 900), 0),
|
||||||
|
('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3),
|
||||||
|
('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3),
|
||||||
|
]) {
|
||||||
|
testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
c.settings.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')];
|
||||||
|
c.videos.add(VideoItem(r'D:\v\a.mp4')
|
||||||
|
..info = const MediaInfo(duration: Duration(minutes: 3), streams: [
|
||||||
|
StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
|
||||||
|
StreamInfo(index: 1, type: 'audio', codec: 'aac'),
|
||||||
|
]));
|
||||||
|
c.selected = c.videos.first;
|
||||||
|
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
|
||||||
|
await tester.pump();
|
||||||
|
final chip = find.text('음성 1개');
|
||||||
|
// 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야)
|
||||||
|
await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80));
|
||||||
|
await tester.drag(find.byType(ListView).last, const Offset(0, -2000));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(chip, findsOneWidget);
|
||||||
|
final r = tester.getRect(chip);
|
||||||
|
for (final b in find.byType(FloatingActionButton).evaluate()) {
|
||||||
|
final br = tester.getRect(find.byWidget(b.widget));
|
||||||
|
expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
|
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
|
||||||
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
|
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||||
|
|
||||||
|
class _Backend implements DownloadBackend {
|
||||||
|
@override
|
||||||
|
final DownloadKind kind;
|
||||||
|
_Backend(this.kind);
|
||||||
|
final started = <String>[];
|
||||||
|
@override
|
||||||
|
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||||
|
@override
|
||||||
|
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||||
|
started.add(t.source);
|
||||||
|
t.state = DownloadState.downloading;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause(DownloadTask t) async {}
|
||||||
|
@override
|
||||||
|
Future<void> cancel(DownloadTask t) async {}
|
||||||
|
@override
|
||||||
|
Future<void> shutdown() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 184 · 185: 직접 파일 주소는 일반 받기 · 다운로드 취소 창의 [계속 받기]
|
||||||
|
void main() {
|
||||||
|
test('184: 직접 파일 주소 알아보기 (동영상 · 소리 · 웹 페이지는 아님)', () {
|
||||||
|
expect(isDirectFileUrl('http://speedtest.tele2.net/100MB.zip'), isTrue);
|
||||||
|
expect(isDirectFileUrl('https://example.com/files/setup.EXE?v=2'), isTrue);
|
||||||
|
expect(isDirectFileUrl('https://example.com/doc.pdf#page=3'), isTrue);
|
||||||
|
expect(isDirectFileUrl('https://example.com/movie.mp4'), isFalse, reason: '동영상은 동영상 받기로');
|
||||||
|
expect(isDirectFileUrl('https://www.youtube.com/watch?v=abc'), isFalse);
|
||||||
|
expect(isDirectFileUrl('https://example.com/page'), isFalse);
|
||||||
|
expect(isDirectFileUrl('https://example.com/download.php?file=a.zip'), isFalse, reason: '경로가 .zip 으로 끝나지 않으면 모름 (동영상 받기 그대로)');
|
||||||
|
expect(detectLinks('http://speedtest.tele2.net/100MB.zip'), isEmpty, reason: '클립보드 자동 감지에는 쓰지 않음');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('184: 직접 파일 주소는 일반 파일 받기 (aria2) 로 · 원래 이름 · aria2 폴더', () {
|
||||||
|
final s = AppSettings();
|
||||||
|
final video = _Backend(DownloadKind.video), files = _Backend(DownloadKind.torrent);
|
||||||
|
final d = DownloadManager(backends: [video, files], settings: () => s, readClipboard: () async => null);
|
||||||
|
addTearDown(d.dispose);
|
||||||
|
final t = d.addPage('http://speedtest.tele2.net/100MB.zip')!;
|
||||||
|
expect(t.kind, DownloadKind.torrent);
|
||||||
|
expect(t.title, '100MB.zip');
|
||||||
|
expect(t.dir, s.aria2Dir);
|
||||||
|
final page = d.addPage('https://example.com/some/page')!;
|
||||||
|
expect(page.kind, DownloadKind.video, reason: '그 밖의 웹 페이지는 지금처럼 동영상 받기');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('185: 다운로드 취소 창 - 취소하지 않는 쪽은 [계속 받기] (옆의 [다운로드 취소] 와 헷갈리지 않게)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1500, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final s = AppSettings();
|
||||||
|
final d = DownloadManager(backends: [_Backend(DownloadKind.video)], settings: () => s, readClipboard: () async => null);
|
||||||
|
addTearDown(d.dispose);
|
||||||
|
final t = d.addPage('https://example.com/some/page')!;
|
||||||
|
d.toggle(t);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(OutlinedButton, '다운로드 취소'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다
|
||||||
|
expect(find.widgetWithText(TextButton, '계속 받기'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '계속 받기'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다
|
||||||
|
expect(t.state, isNot(DownloadState.cancelled));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||||
|
|
||||||
|
/// 188: 탐색기 가운데 버튼 줄의 글이 두 줄로 꺾여도 버튼 아래로 잘리지 않는다 (일본어 "新しいフォルダー" · 세로 화면)
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_labels_'));
|
||||||
|
tearDown(() {
|
||||||
|
setTranslations('ko', const {});
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, String> dict(String code) =>
|
||||||
|
Map<String, String>.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map);
|
||||||
|
|
||||||
|
for (final lang in ['ko', 'en', 'ja', 'zh-Hans']) {
|
||||||
|
for (final size in [const Size(800, 1280), const Size(1280, 800), const Size(412, 900)]) {
|
||||||
|
for (final scale in [1.0, 1.6]) {
|
||||||
|
testWidgets('188: 버튼 글이 버튼 안에 다 보인다 ($lang, ${size.width.round()}×${size.height.round()}, 글자 $scale배)', (tester) async {
|
||||||
|
setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en'));
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
c.settings.explorerPaths = [tmp.path, tmp.path];
|
||||||
|
// 기기 글꼴 · 글자 크기가 시험 글꼴보다 크다 (S10 일본어에서 잘림) - 글자 크기를 키운 경우도
|
||||||
|
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
|
||||||
|
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
|
||||||
|
home: ExplorerPage(c: c))));
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
// 1.6배에서는 목록 줄 (높이 고정) 이 넘친다 - 따로 알림 (이 시험은 버튼 줄만 본다)
|
||||||
|
if (scale == 1.0) {
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
} else {
|
||||||
|
tester.takeException();
|
||||||
|
}
|
||||||
|
final label = find.text(tr('새 폴더'));
|
||||||
|
if (label.evaluate().isEmpty) return; // 좁아서 [더 보기] 메뉴로 들어감
|
||||||
|
final text = tester.getRect(label.first);
|
||||||
|
final button = tester.getRect(find.ancestor(of: label.first, matching: find.byType(InkWell)).first);
|
||||||
|
expect(text.bottom, lessThanOrEqualTo(button.bottom + 0.5), reason: '글 $text · 버튼 $button');
|
||||||
|
expect(text.top, greaterThanOrEqualTo(button.top - 0.5));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -202,5 +202,40 @@ void main() {
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(requested, 1);
|
expect(requested, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('199: 앱 목록이 열려 (노트 10+) 켜지 않고 돌아오면 "목록에서 찾아 켜세요" · [다시 열기], 켜고 돌아오면 끝', (t) async {
|
||||||
|
var requested = 0;
|
||||||
|
var allowed = false;
|
||||||
|
await t.pumpWidget(MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) => TextButton(
|
||||||
|
onPressed: () => showAllFilesGuide(ctx, () async => requested++, hasAccess: () async => allowed),
|
||||||
|
child: const Text('go'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
Future<void> goAndComeBack() async {
|
||||||
|
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.tap(find.text('go'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.textContaining('목록에서 JJ_MKVMaker 를 찾아 켜세요'), findsOneWidget, reason: '처음 창에도 한 줄');
|
||||||
|
await t.tap(find.text('허용'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(requested, 1);
|
||||||
|
await goAndComeBack(); // 켜지 않고 돌아옴
|
||||||
|
expect(find.text('아직 허용되지 않았습니다'), findsOneWidget);
|
||||||
|
await t.tap(find.text('다시 열기'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(requested, 2);
|
||||||
|
allowed = true;
|
||||||
|
await goAndComeBack(); // 켜고 돌아옴
|
||||||
|
expect(find.text('아직 허용되지 않았습니다'), findsNothing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue