diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 815e0d3..e428ef6 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -683,6 +683,7 @@ "같은 네트워크에서만 열 수 있습니다": "Can only be opened from the same network", "같은 망에서 찾기": "Find on this network", "같은 망의 SMB 서버": "SMB servers on this network", + "같은 망의 미디어 서버를 찾는 중 …": "Looking for media servers on this network …", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", @@ -1017,6 +1018,7 @@ "다시 시작": "Restart", "다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.", "다시 열기": "Open again", + "다시 찾기": "Search again", "다시 켤 때 동기화": "Sync on restart", "다시 확인": "Check again", "다운로드": "Downloads", @@ -1313,6 +1315,8 @@ "묻기": "Ask", "묻기 (기본)": "Ask (default)", "물어보지 않기": "Never ask", + "미디어 서버 (DLNA)": "Media servers (DLNA)", + "미디어 서버를 찾지 못했습니다. Wi-Fi · 유선에 연결돼 있는지, 서버 (NAS 의 미디어 서버 등) 가 켜져 있는지 확인하세요": "No media server found. Check that you are on Wi-Fi or wired network and that the server (e.g. the NAS media server) is running", "미리 보기": "Quick look", "미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "The preview shows nothing to delete - but because of the rules above, items may actually be deleted", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}", @@ -1448,6 +1452,7 @@ "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "Shows [Improve quality] in the viewer: refines with AI image (image → image, light) and then upscales. Saves a new file (name_q); needs the AI image model", "보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "In the viewer, volume down = next, volume up = previous (videos and other screens keep normal volume keys)", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page", + "보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다": "View only - finds media servers on the same Wi-Fi or wired network only; no uploading or deleting", "보내지 못했습니다": "Could not send", "보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder", @@ -1498,6 +1503,7 @@ "복사했습니다.": "copied.", "볼 그림이 없습니다.": "No images to show.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", + "볼 수 없는 종류입니다": "This type cannot be viewed", "볼륨 키로 넘기기": "Turn pages with volume keys", "분석": "Analyze", "분석 실패: {0} - {1}": "Analysis failed: {0} - {1}", @@ -1628,6 +1634,7 @@ "서버가 이 기능을 허용하지 않음": "the server does not allow this", "서버에 닿을 수 없음": "host unreachable", "서버에 닿지 않습니다": "Cannot reach the server", + "서버에서 목록을 읽지 못했습니다: {0}": "Could not read the list from the server: {0}", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].", "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "Couldn't agree on an encryption method with the server. If it's an old server, try turning on \"Allow old ssh-rsa\" in the server settings", @@ -1903,6 +1910,7 @@ "연결할 수 없습니다": "Cannot connect", "연도": "Year", "열 수 없습니다: {0}": "Cannot open: {0}", + "열 수 없음": "Cannot open", "열기": "Open", "열기 · 재생": "Open · Play", "열어 본 때: {0}": "Accessed: {0}", @@ -2169,6 +2177,7 @@ "이 폴더의 동영상 재생": "Play videos in this folder", "이 폴더의 파일": "Files in this folder", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "This app is running as administrator. This is not recommended.\n\n· The built-in Chrome engine has its sandbox (the protection that confines web pages) turned off, so web pages run inside an administrator process. A flaw in a page could become administrator access to this PC.\n· The Edge engine is not affected.\n· If you run the app without administrator rights, you can use the Chrome engine without this setting.", + "이 항목은 같은 망 밖의 주소라 열지 않습니다": "This item points outside the local network, so it is not opened", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "No app opens this format. Long-press or right-click → [Play in built-in player] to try it.", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "This screen sends the in-app web view's connections through Tor; it is not the official Tor Browser.\n\n· All connections (including name lookups) go out through Tor, and cookies, history and cache are kept separate from the normal browser and cleared when closed (can be kept in Settings).\n· However, protection against browser fingerprinting (screen size, fonts, device information, etc.) is weaker than in the official Tor Browser. Sites may recognize this device.\n· If you need strong anonymity, use the official Tor Browser (https://www.torproject.org).", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "Double-tapping files with these extensions opens the image viewer (tap right for the next image, left for the previous).", @@ -2602,6 +2611,7 @@ "찾을 글": "Find", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "Not found (already deleted or moved)", "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "Nothing found. Check that the NAS is on and on the same network, or enter the address yourself.", + "찾지 못했습니다: {0}": "Search failed: {0}", "채도": "Saturation", "채우기 (가운데 잘라냄)": "Fill (crop the center)", "처리 장치": "Processor", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index f92945a..cf21f5e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -683,6 +683,7 @@ "같은 네트워크에서만 열 수 있습니다": "同じネットワークからのみ開けます", "같은 망에서 찾기": "同じネットワークで探す", "같은 망의 SMB 서버": "同じネットワークの SMB サーバー", + "같은 망의 미디어 서버를 찾는 중 …": "同じネットワークのメディアサーバーを探しています …", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", @@ -1017,6 +1018,7 @@ "다시 시작": "再起動", "다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。", "다시 열기": "もう一度開く", + "다시 찾기": "再検索", "다시 켤 때 동기화": "再起動時の同期", "다시 확인": "再確認", "다운로드": "ダウンロード", @@ -1313,6 +1315,8 @@ "묻기": "確認する", "묻기 (기본)": "確認する (既定)", "물어보지 않기": "尋ねない", + "미디어 서버 (DLNA)": "メディアサーバー (DLNA)", + "미디어 서버를 찾지 못했습니다. Wi-Fi · 유선에 연결돼 있는지, 서버 (NAS 의 미디어 서버 등) 가 켜져 있는지 확인하세요": "メディアサーバーが見つかりません。Wi-Fi・有線に接続しているか、サーバー (NAS のメディアサーバーなど) が動いているか確認してください", "미리 보기": "クイックプレビュー", "미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "プレビューでは削除するものはありません - ただし上のルールのため実際には削除されることがあります", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", @@ -1448,6 +1452,7 @@ "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です", "보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "ビューアーで音量下 = 次、音量上 = 前 (動画・ほかの画面の音量はそのまま)", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", + "보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다": "閲覧のみ - 同じ Wi-Fi・有線ネットワークのメディアサーバーだけを探し、アップロード・削除はありません", "보내지 못했습니다": "送信できませんでした", "보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました", @@ -1498,6 +1503,7 @@ "복사했습니다.": "コピーしました。", "볼 그림이 없습니다.": "表示する画像がありません。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", + "볼 수 없는 종류입니다": "表示できない種類です", "볼륨 키로 넘기기": "音量キーでページ送り", "분석": "分析", "분석 실패: {0} - {1}": "分析失敗: {0} - {1}", @@ -1628,6 +1634,7 @@ "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", "서버에 닿을 수 없음": "ホストに到達できません", "서버에 닿지 않습니다": "サーバーに接続できません", + "서버에서 목록을 읽지 못했습니다: {0}": "サーバーから一覧を読み込めませんでした: {0}", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。", "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "サーバーと暗号方式を合わせられませんでした。古いサーバーの場合はサーバー設定の「古い ssh-rsa を許可」をオンにしてみてください", @@ -1903,6 +1910,7 @@ "연결할 수 없습니다": "接続できません", "연도": "年", "열 수 없습니다: {0}": "開けません: {0}", + "열 수 없음": "開けません", "열기": "開く", "열기 · 재생": "開く・再生", "열어 본 때: {0}": "アクセス日時: {0}", @@ -2169,6 +2177,7 @@ "이 폴더의 동영상 재생": "このフォルダーの動画を再生", "이 폴더의 파일": "このフォルダーのファイル", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "このアプリは管理者権限で実行されています。おすすめしません。\n\n· 内蔵 Chrome エンジンはサンドボックス (Web ページを閉じ込める保護) がオフのため、Web ページが管理者権限のプロセス内で動きます。ページの脆弱性がそのままこの PC の管理者権限になるおそれがあります。\n· Edge エンジンにはこの影響がありません。\n· 管理者権限なしでアプリを実行すれば、この設定なしで Chrome エンジンを使えます。", + "이 항목은 같은 망 밖의 주소라 열지 않습니다": "この項目はローカルネットワーク外のアドレスのため開きません", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "この形式を開くアプリがありません。長押しまたは右クリック → [内蔵プレーヤーで再生] で試してください。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "この画面はアプリ内 Web ビューの接続を Tor 経由にするもので、公式の Tor Browser ではありません。\n\n・接続 (名前解決を含む) はすべて Tor 経由になり、Cookie・履歴・キャッシュは通常のブラウザーとは別に保存し、閉じると消去します (環境設定で残すことも可能)。\n・ただし、ブラウザーのフィンガープリント (画面サイズ・フォント・端末情報など) を隠す保護は公式 Tor Browser より弱いです。サイトがこの端末を識別できる場合があります。\n・強い匿名性が必要な場合は公式 Tor Browser (https://www.torproject.org) を使ってください。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "この拡張子のファイルをダブルタップすると画像表示で開きます (右をタップで次、左で前の画像)。", @@ -2602,6 +2611,7 @@ "찾을 글": "検索する文字", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "見つかりません (すでに削除または移動されました)", "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "見つかりませんでした。NAS の電源が入っていて同じネットワークにあるか確認するか、アドレスを直接入力してください。", + "찾지 못했습니다: {0}": "検索できませんでした: {0}", "채도": "彩度", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "처리 장치": "処理装置", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index fe22ff2..17b6352 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -683,6 +683,7 @@ "같은 네트워크에서만 열 수 있습니다": "只能在同一网络中打开", "같은 망에서 찾기": "在同一网络中查找", "같은 망의 SMB 서버": "同一网络中的 SMB 服务器", + "같은 망의 미디어 서버를 찾는 중 …": "正在查找同一网络中的媒体服务器 …", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", @@ -1017,6 +1018,7 @@ "다시 시작": "重新启动", "다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。", "다시 열기": "再次打开", + "다시 찾기": "重新查找", "다시 켤 때 동기화": "重新启动时同步", "다시 확인": "重新检查", "다운로드": "下载", @@ -1313,6 +1315,8 @@ "묻기": "询问", "묻기 (기본)": "询问 (默认)", "물어보지 않기": "不询问", + "미디어 서버 (DLNA)": "媒体服务器 (DLNA)", + "미디어 서버를 찾지 못했습니다. Wi-Fi · 유선에 연결돼 있는지, 서버 (NAS 의 미디어 서버 등) 가 켜져 있는지 확인하세요": "未找到媒体服务器。请确认已连接 Wi-Fi 或有线网络,且服务器 (如 NAS 的媒体服务器) 已开启", "미리 보기": "快速预览", "미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "预览中没有要删除的项目 - 但由于上面的规则,实际上可能会被删除", "미리 볼 수 없습니다: {0}": "无法预览: {0}", @@ -1448,6 +1452,7 @@ "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型", "보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "在查看器中音量减 = 下一张, 音量加 = 上一张 (视频和其他界面的音量不变)", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", + "보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다": "仅查看 - 只查找同一 Wi-Fi 或有线网络中的媒体服务器,不能上传或删除", "보내지 못했습니다": "无法发送", "보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹", @@ -1498,6 +1503,7 @@ "복사했습니다.": "已复制。", "볼 그림이 없습니다.": "没有可显示的图片。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", + "볼 수 없는 종류입니다": "无法查看此类型", "볼륨 키로 넘기기": "用音量键翻页", "분석": "分析", "분석 실패: {0} - {1}": "分析失败: {0} - {1}", @@ -1628,6 +1634,7 @@ "서버가 이 기능을 허용하지 않음": "服务器不允许此操作", "서버에 닿을 수 없음": "主机不可达", "서버에 닿지 않습니다": "无法连接服务器", + "서버에서 목록을 읽지 못했습니다: {0}": "无法从服务器读取列表: {0}", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。", "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "无法与服务器协商加密方式。如果是旧服务器,请在服务器设置中开启\"允许旧的 ssh-rsa\"", @@ -1903,6 +1910,7 @@ "연결할 수 없습니다": "无法连接", "연도": "年份", "열 수 없습니다: {0}": "无法打开: {0}", + "열 수 없음": "无法打开", "열기": "打开", "열기 · 재생": "打开 · 播放", "열어 본 때: {0}": "访问时间: {0}", @@ -2169,6 +2177,7 @@ "이 폴더의 동영상 재생": "播放此文件夹中的视频", "이 폴더의 파일": "此文件夹中的文件", "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "本应用正以管理员权限运行,不推荐这样做。\n\n· 内置 Chrome 引擎的沙盒(限制网页的保护)已关闭,网页会在管理员权限进程中运行。网页中的漏洞可能直接变成此电脑的管理员权限。\n· Edge 引擎不受此影响。\n· 不以管理员权限运行本应用时,无需此设置即可使用 Chrome 引擎。", + "이 항목은 같은 망 밖의 주소라 열지 않습니다": "此项目指向本地网络之外的地址,因此不会打开", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "没有可打开此格式的应用。请长按或右键 → [用内置播放器播放] 试试。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "此画面将应用内网页视图的连接经由 Tor 发送,并非官方 Tor Browser。\n\n· 所有连接 (包括名称解析) 都经由 Tor,Cookie · 记录 · 缓存与普通浏览器分开保存,关闭时清除 (可在设置中保留)。\n· 但隐藏浏览器指纹 (屏幕尺寸 · 字体 · 设备信息等) 的保护比官方 Tor Browser 弱。网站可能识别出本设备。\n· 如需强匿名性,请使用官方 Tor Browser (https://www.torproject.org)。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "双击这些扩展名的文件会用图片查看打开 (点右侧看下一张,点左侧看上一张)。", @@ -2602,6 +2611,7 @@ "찾을 글": "查找", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "找不到 (已被删除或移动)", "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "未找到。请确认 NAS 已开启且在同一网络中,或直接输入地址。", + "찾지 못했습니다: {0}": "查找失败: {0}", "채도": "饱和度", "채우기 (가운데 잘라냄)": "填充(裁切中间)", "처리 장치": "处理器", diff --git a/lib/core/dlna.dart b/lib/core/dlna.dart new file mode 100644 index 0000000..328d751 --- /dev/null +++ b/lib/core/dlna.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:xml/xml.dart'; + +import 'lan_scan.dart' show isPrivateLanAddress, localPrivateIPv4; + +/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만", 관리자 지시): 같은 망의 미디어 서버 (UPnP AV MediaServer) 를 찾아 폴더 · 동영상 · +/// 사진 · 음악을 목록으로 보고 재생한다. 서버 (내보내기) 는 넣지 않는다. 새 패키지 없음 (xml 은 이미 씀). +/// - 찾기 (SSDP M-SEARCH) 는 Wi-Fi · 유선의 사설 주소에서만 (모바일 데이터 · VPN · 가상 어댑터로는 보내지 않음 - SMB 찾기와 같은 판단) +/// - 서버가 알려 준 주소 (설명 · 제어 · 미디어) 는 사설망 · 이 기기 주소만 따른다 (서버가 바깥 주소로 이끌지 못하게) +class DlnaServer { + final String name, location, controlUrl; + const DlnaServer(this.name, this.location, this.controlUrl); + String get host => Uri.parse(location).host; +} + +class DlnaEntry { + final String id, title; + final bool isContainer; + final int? childCount, size; + final String? url, mime, upnpClass; + final Duration? duration; + const DlnaEntry(this.id, this.title, + {this.isContainer = false, this.childCount, this.size, this.url, this.mime, this.upnpClass, this.duration}); + + /// 'video' · 'audio' · 'image' · 'other' + String get kind { + final c = (upnpClass ?? '').toLowerCase(); + final m = (mime ?? '').toLowerCase(); + if (c.contains('videoitem') || m.startsWith('video/')) return 'video'; + if (c.contains('audioitem') || m.startsWith('audio/')) return 'audio'; + if (c.contains('imageitem') || m.startsWith('image/')) return 'image'; + return 'other'; + } +} + +/// Browse 한 쪽 (전체 수는 서버가 알려 준 TotalMatches) +class DlnaListing { + final List entries; + final int total; + const DlnaListing(this.entries, this.total); +} + +class DlnaException implements Exception { + final String message; + const DlnaException(this.message); + @override + String toString() => message; +} + +/// 주소가 따라가도 되는 곳인지 (사설망 IPv4 · 이 기기) +bool dlnaHostAllowed(String host) { + final a = InternetAddress.tryParse(host); + if (a == null) return false; + return a.isLoopback || (a.type == InternetAddressType.IPv4 && isPrivateLanAddress(host)); +} + +const _st = 'urn:schemas-upnp-org:device:MediaServer:1'; + +/// SSDP 찾기 요청 +String ssdpSearchMessage({int mx = 2}) => 'M-SEARCH * HTTP/1.1\r\n' + 'HOST: 239.255.255.250:1900\r\n' + 'MAN: "ssdp:discover"\r\n' + 'MX: $mx\r\n' + 'ST: $_st\r\n' + '\r\n'; + +/// SSDP 답에서 LOCATION (머리 이름은 대소문자 무시). 미디어 서버 답이 아니면 null +String? parseSsdpLocation(String response) { + final lines = const LineSplitter().convert(response); + if (lines.isEmpty || !lines.first.toUpperCase().startsWith('HTTP/1.1 200')) return null; + final h = {}; + for (final l in lines.skip(1)) { + final i = l.indexOf(':'); + if (i > 0) h[l.substring(0, i).trim().toLowerCase()] = l.substring(i + 1).trim(); + } + final st = h['st'] ?? h['nt'] ?? ''; + if (!st.toLowerCase().contains('mediaserver') && !st.toLowerCase().contains('contentdirectory')) return null; + return h['location']; +} + +/// 장치 설명 XML → 이름 · ContentDirectory 제어 주소 (상대 주소는 URLBase 나 [location] 기준) +DlnaServer? parseDescription(String xml, String location) { + final doc = XmlDocument.parse(xml); + String? text(XmlElement e, String name) => e.findAllElements(name).firstOrNull?.innerText.trim(); + final root = doc.rootElement; + final base = text(root, 'URLBase'); + final device = root.findAllElements('device').firstOrNull; + if (device == null) return null; + final name = text(device, 'friendlyName') ?? Uri.parse(location).host; + for (final s in root.findAllElements('service')) { + final type = text(s, 'serviceType') ?? ''; + if (!type.contains('ContentDirectory')) continue; + final ctl = text(s, 'controlURL'); + if (ctl == null) return null; + final url = Uri.parse(base?.isNotEmpty == true ? base! : location).resolve(ctl).toString(); + return DlnaServer(name, location, url); + } + return null; +} + +/// Browse 의 Result (DIDL-Lite) → 항목. 미디어 주소가 사설망 · 이 기기가 아니면 그 항목은 주소 없이 +List parseDidl(String didl) { + final doc = XmlDocument.parse(didl); + final out = []; + String? t(XmlElement e, String local) => + e.childElements.where((x) => x.name.local == local).firstOrNull?.innerText.trim(); + for (final e in doc.rootElement.childElements) { + final id = e.getAttribute('id') ?? ''; + final title = t(e, 'title') ?? id; + final cls = t(e, 'class'); + if (e.name.local == 'container') { + out.add(DlnaEntry(id, title, isContainer: true, childCount: int.tryParse(e.getAttribute('childCount') ?? ''), upnpClass: cls)); + } else if (e.name.local == 'item') { + final res = e.childElements.where((x) => x.name.local == 'res').firstOrNull; + var url = res?.innerText.trim(); + if (url != null) { + final u = Uri.tryParse(url); + if (u == null || !(u.scheme == 'http' || u.scheme == 'https') || !dlnaHostAllowed(u.host)) url = null; + } + final proto = res?.getAttribute('protocolInfo') ?? ''; + final parts = proto.split(':'); + out.add(DlnaEntry(id, title, + url: url, + mime: parts.length > 2 ? parts[2] : null, + size: int.tryParse(res?.getAttribute('size') ?? ''), + duration: _duration(res?.getAttribute('duration')), + upnpClass: cls)); + } + } + return out; +} + +Duration? _duration(String? s) { + if (s == null) return null; + final m = RegExp(r'^(\d+):(\d{1,2}):(\d{1,2})(?:\.(\d+))?$').firstMatch(s.trim()); + if (m == null) return null; + return Duration(hours: int.parse(m[1]!), minutes: int.parse(m[2]!), seconds: int.parse(m[3]!)); +} + +/// 같은 망의 미디어 서버 찾기. [addresses] 는 보낼 이 기기 주소 (처음: Wi-Fi · 유선의 사설 IPv4 - 없으면 빈 목록) +Future> discoverDlna({Duration wait = const Duration(seconds: 3), List? addresses}) async { + final from = addresses ?? await localPrivateIPv4(); + final locations = {}; + final sockets = []; + final msg = utf8.encode(ssdpSearchMessage()); + final group = InternetAddress('239.255.255.250'); + for (final a in from) { + try { + final s = await RawDatagramSocket.bind(a, 0); + s.multicastHops = 2; + sockets.add(s); + s.listen((ev) { + if (ev != RawSocketEvent.read) return; + final d = s.receive(); + if (d == null) return; + final loc = parseSsdpLocation(utf8.decode(d.data, allowMalformed: true)); + final u = loc == null ? null : Uri.tryParse(loc); + if (u != null && u.scheme == 'http' && dlnaHostAllowed(u.host)) locations.add(loc!); + }); + s.send(msg, group, 1900); + // UDP 는 잃어버릴 수 있어 한 번 더 + Timer(const Duration(milliseconds: 400), () { + try { + s.send(msg, group, 1900); + } catch (_) {} + }); + } catch (_) {} + } + if (sockets.isEmpty) return const []; + await Future.delayed(wait); + for (final s in sockets) { + s.close(); + } + final out = []; + for (final loc in locations) { + try { + final server = parseDescription(await _get(loc), loc); + if (server != null && dlnaHostAllowed(Uri.parse(server.controlUrl).host) && !out.any((x) => x.controlUrl == server.controlUrl)) { + out.add(server); + } + } catch (_) {} + } + out.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return out; +} + +/// 작은 XML 받기 (설명 · Browse 답) - 정한 시간 · 크기까지만 +Future _get(String url, {String method = 'GET', String? body, Map headers = const {}}) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 4); + try { + final req = await client.openUrl(method, Uri.parse(url)).timeout(const Duration(seconds: 6)); + req.followRedirects = false; + headers.forEach(req.headers.set); + if (body != null) { + final bytes = utf8.encode(body); + req.headers.contentType = ContentType('text', 'xml', charset: 'utf-8'); + req.contentLength = bytes.length; + req.add(bytes); + } + final res = await req.close().timeout(const Duration(seconds: 10)); + final buf = BytesBuilder(copy: false); + await for (final chunk in res.timeout(const Duration(seconds: 10))) { + buf.add(chunk); + if (buf.length > 4 * 1024 * 1024) throw const DlnaException('too big'); + } + if (res.statusCode >= 300) throw DlnaException('HTTP ${res.statusCode}'); + return utf8.decode(buf.takeBytes(), allowMalformed: true); + } finally { + client.close(force: true); + } +} + +String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + +/// 폴더 [objectId] (뿌리 '0') 안의 [start] 부터 [count] 개 +Future dlnaBrowse(DlnaServer server, {String objectId = '0', int start = 0, int count = 200}) async { + if (!dlnaHostAllowed(Uri.parse(server.controlUrl).host)) throw const DlnaException('host'); + final body = '' + '' + '' + '${_esc(objectId)}BrowseDirectChildren*' + '$start$count' + ''; + final xml = await _get(server.controlUrl, + method: 'POST', body: body, headers: {'SOAPACTION': '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"'}); + final doc = XmlDocument.parse(xml); + String? t(String local) => doc.descendants.whereType().where((e) => e.name.local == local).firstOrNull?.innerText; + final result = t('Result'); + if (result == null) throw const DlnaException('no result'); + final entries = result.trim().isEmpty ? [] : parseDidl(result); + return DlnaListing(entries, int.tryParse(t('TotalMatches') ?? '') ?? entries.length); +} diff --git a/lib/ui/dlna_page.dart b/lib/ui/dlna_page.dart new file mode 100644 index 0000000..0510960 --- /dev/null +++ b/lib/ui/dlna_page.dart @@ -0,0 +1,268 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../core/dlna.dart'; +import '../core/file_ops.dart' show formatSize; +import '../core/reader_sources.dart'; +import '../l10n/tr.dart'; +import 'player_page.dart'; +import 'reader_page.dart' show openReader; +import 'theme.dart'; + +/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만"): 같은 Wi-Fi 의 미디어 서버 보기 · 재생 (서버 · 올리기 · 지우기 없음) +Future openDlna(BuildContext context, AppController c) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => DlnaPage(c: c))); + +class DlnaPage extends StatefulWidget { + final AppController c; + + /// 시험: 찾기 · 읽기를 바꿔 끼움 + final Future> Function()? discover; + final Future Function(DlnaServer s, String id, int start)? browse; + const DlnaPage({super.key, required this.c, this.discover, this.browse}); + + @override + State createState() => _DlnaPageState(); +} + +/// [dlnaBrowse] 의 결과 (시험에서 만들 수 있게) +typedef DlnaResult = ({List entries, int total}); + +class _DlnaPageState extends State { + List? _servers; + bool _finding = false; + DlnaServer? _server; + + /// 들어간 폴더들 (id, 이름) - 맨 앞은 뿌리 + final _path = <(String, String)>[]; + List _entries = const []; + int _total = 0; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_find()); + } + + Future _find() async { + setState(() { + _finding = true; + _error = null; + }); + try { + final list = await (widget.discover ?? discoverDlna)(); + if (mounted) setState(() => _servers = list); + } catch (e) { + if (mounted) setState(() => _error = trf('찾지 못했습니다: {0}', [e])); + } finally { + if (mounted) setState(() => _finding = false); + } + } + + Future _browse(DlnaServer s, String id, int start) async { + if (widget.browse != null) return widget.browse!(s, id, start); + final page = await dlnaBrowse(s, objectId: id, start: start); + return (entries: page.entries, total: page.total); + } + + Future _open(DlnaServer s, String id, String name, {bool push = true}) async { + setState(() { + _server = s; + if (push) _path.add((id, name)); + _entries = const []; + _loading = true; + _error = null; + }); + try { + final r = await _browse(s, id, 0); + if (!mounted) return; + setState(() { + _entries = r.entries; + _total = r.total; + }); + } catch (e) { + if (mounted) setState(() => _error = trf('서버에서 목록을 읽지 못했습니다: {0}', [e])); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _more() async { + final s = _server!; + setState(() => _loading = true); + try { + final r = await _browse(s, _path.last.$1, _entries.length); + if (mounted) setState(() => _entries = [..._entries, ...r.entries]); + } catch (e) { + if (mounted) setState(() => _error = trf('서버에서 목록을 읽지 못했습니다: {0}', [e])); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _up() { + if (_path.length > 1) { + _path.removeLast(); + unawaited(_open(_server!, _path.last.$1, _path.last.$2, push: false)); + } else { + setState(() { + _server = null; + _path.clear(); + _entries = const []; + }); + } + } + + Future _tap(DlnaEntry e) async { + if (e.isContainer) return _open(_server!, e.id, e.title); + final url = e.url; + if (url == null) { + _snack(tr('이 항목은 같은 망 밖의 주소라 열지 않습니다')); + return; + } + switch (e.kind) { + case 'video' || 'audio': + // 같은 폴더의 같은 종류를 차례대로 (다음 화로 넘어가게) + final list = [for (final x in _entries) if (!x.isContainer && x.url != null && x.kind == e.kind) x]; + final create = widget.c.services.createMediaPlayer; + if (create == null) return; + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PlayerPage(c: widget.c, player: create(), files: [for (final x in list) x.url!], start: list.indexOf(e)))); + case 'image': + await _viewImage(e); + default: + _snack(tr('볼 수 없는 종류입니다')); + } + } + + /// 그림은 임시로 받아서 보기 화면으로 (50MB 까지) + Future _viewImage(DlnaEntry e) async { + try { + final dir = Directory(p.join(await widget.c.services.storage.tempDirectory(), 'jj_dlna')); + await dir.create(recursive: true); + final ext = switch (e.mime) { 'image/png' => '.png', 'image/gif' => '.gif', 'image/webp' => '.webp', _ => '.jpg' }; + final out = File(p.join(dir.path, 'view$ext')); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + try { + final res = await (await client.getUrl(Uri.parse(e.url!))).close(); + if (res.statusCode >= 300) throw HttpException('HTTP ${res.statusCode}'); + final sink = out.openWrite(); + var n = 0; + await for (final chunk in res) { + n += chunk.length; + if (n > 50 * 1024 * 1024) throw const HttpException('too big'); + sink.add(chunk); + } + await sink.close(); + } finally { + client.close(force: true); + } + if (!mounted) return; + await openReader(context, widget.c, ImageFilesSource([out.path], tempDir: dir.path, title: e.title)); + } catch (err) { + _snack(trf('열 수 없습니다: {0}', [err])); + } + } + + void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t))); + + IconData _icon(DlnaEntry e) => e.isContainer + ? Icons.folder + : switch (e.kind) { 'video' => Icons.movie_outlined, 'audio' => Icons.audiotrack_outlined, 'image' => Icons.image_outlined, _ => Icons.insert_drive_file_outlined }; + + String _d(Duration d) => [if (d.inHours > 0) '${d.inHours}', (d.inMinutes % 60).toString().padLeft(d.inHours > 0 ? 2 : 1, '0'), (d.inSeconds % 60).toString().padLeft(2, '0')].join(':'); + + @override + Widget build(BuildContext context) { + final s = _server; + return PopScope( + canPop: s == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _up(); + }, + child: Scaffold( + appBar: AppBar( + title: Text(s == null ? tr('미디어 서버 (DLNA)') : [s.name, ..._path.skip(1).map((x) => x.$2)].join(' / '), + maxLines: 1, overflow: TextOverflow.ellipsis), + leading: s == null ? null : IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: _up), + actions: [ + if (s == null) + IconButton(tooltip: tr('다시 찾기'), icon: const Icon(Icons.refresh), onPressed: _finding ? null : _find), + ], + ), + body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text(tr('보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))), + if (s == null) Expanded(child: _serverList()) else Expanded(child: _entryList()), + ]), + ), + ); + } + + Widget _serverList() { + final list = _servers; + if (_finding && (list == null || list.isEmpty)) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(tr('같은 망의 미디어 서버를 찾는 중 …')), + ]), + ); + } + if (list == null || list.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(tr('미디어 서버를 찾지 못했습니다. Wi-Fi · 유선에 연결돼 있는지, 서버 (NAS 의 미디어 서버 등) 가 켜져 있는지 확인하세요'), + textAlign: TextAlign.center), + ), + ); + } + return ListView(children: [ + for (final x in list) + ListTile( + leading: const Icon(Icons.dns_outlined), + title: Text(x.name), + subtitle: Text(x.host, style: const TextStyle(color: JjColors.textDim)), + onTap: () => _open(x, '0', x.name), + ), + ]); + } + + Widget _entryList() { + if (_loading && _entries.isEmpty) return const Center(child: CircularProgressIndicator()); + if (!_loading && _entries.isEmpty && _error == null) return Center(child: Text(tr('비어 있음'))); + return ListView.builder( + itemCount: _entries.length + (_entries.length < _total ? 1 : 0), + itemBuilder: (_, i) { + if (i == _entries.length) { + return Padding( + padding: const EdgeInsets.all(8), + child: OutlinedButton(onPressed: _loading ? null : _more, child: Text(trf('더 보기 ({0} / {1})', [_entries.length, _total]))), + ); + } + final e = _entries[i]; + return ListTile( + dense: true, + leading: Icon(_icon(e), color: e.isContainer ? Colors.amber : JjColors.textDim), + title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: e.isContainer + ? (e.childCount == null ? null : Text(trf('{0}개', [e.childCount!]))) + : Text([if (e.size != null) formatSize(e.size!), if (e.duration != null) _d(e.duration!), if (e.url == null) tr('열 수 없음')].join(' · ')), + onTap: () => _tap(e), + ); + }, + ); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index df04f39..f9dcc8d 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -58,6 +58,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'remote_thumb_view.dart'; +import 'dlna_page.dart' show openDlna; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'quick_look.dart'; @@ -5298,6 +5299,7 @@ class _ExplorerPageState extends State { 'sftp' => _editSftp(pane, null), 'smb' => _editSmb(pane, null), 'ftp' => _editFtp(pane, null), + 'dlna' => openDlna(context, c), _ => _editDav(pane, null), }, itemBuilder: (_) => [ @@ -5314,6 +5316,10 @@ class _ExplorerPageState extends State { if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) PopupMenuItem( value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))), + // X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만) + if (!widget.rsync) + PopupMenuItem( + value: 'dlna', child: ListTile(dense: true, leading: const Icon(Icons.cast_outlined), title: Text(tr('미디어 서버 (DLNA)')))), ], ); @@ -5473,6 +5479,14 @@ class _ExplorerPageState extends State { icon: const Icon(Icons.folder_shared_outlined, size: 16), onPressed: () => _editFtp(pane, null), ), + // X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만) + if (!widget.rsync) + IconButton( + tooltip: tr('미디어 서버 (DLNA)'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.cast_outlined, size: 16), + onPressed: () => openDlna(context, c), + ), IconButton( tooltip: tr('경로 입력'), visualDensity: VisualDensity.compact, diff --git a/test/dlna_test.dart b/test/dlna_test.dart new file mode 100644 index 0000000..b4d0289 --- /dev/null +++ b/test/dlna_test.dart @@ -0,0 +1,142 @@ +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/core/dlna.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/dlna_page.dart'; + +/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만"): 찾기 답 · 장치 설명 · DIDL 읽기, 같은 망 밖 주소는 따르지 않음, 화면 +void main() { + String didl(String body) => '$body'; + + test('SSDP 답: 미디어 서버의 LOCATION 만 (머리 이름 대소문자 무시)', () { + expect(parseSsdpLocation('HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nlocation: http://192.168.0.5:8200/rootDesc.xml\r\n' + 'ST: urn:schemas-upnp-org:device:MediaServer:1\r\n\r\n'), 'http://192.168.0.5:8200/rootDesc.xml'); + expect(parseSsdpLocation('HTTP/1.1 200 OK\r\nLOCATION: http://x/\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n'), isNull); + expect(parseSsdpLocation('NOTIFY * HTTP/1.1\r\nLOCATION: http://x/\r\n\r\n'), isNull); + expect(ssdpSearchMessage(), contains('ST: urn:schemas-upnp-org:device:MediaServer:1')); + }); + + test('장치 설명: 이름 · ContentDirectory 제어 주소 (상대 주소는 URLBase · LOCATION 기준)', () { + const xml = '집 NAS' + 'urn:schemas-upnp-org:service:ConnectionManager:1/cm' + 'urn:schemas-upnp-org:service:ContentDirectory:1ctl/ContentDir' + ''; + final s = parseDescription(xml, 'http://192.168.0.5:8200/rootDesc.xml')!; + expect((s.name, s.controlUrl), ('집 NAS', 'http://192.168.0.5:8200/ctl/ContentDir')); + final withBase = parseDescription(xml.replaceFirst('', 'http://192.168.0.9:9000/'), + 'http://192.168.0.5:8200/rootDesc.xml')!; + expect(withBase.controlUrl, 'http://192.168.0.9:9000/ctl/ContentDir'); + expect(parseDescription('x', 'http://1.2.3.4/'), isNull); + }); + + test('DIDL: 폴더 · 동영상 · 음악 · 사진 · 크기 · 길이, 같은 망 밖의 주소는 열지 않음', () { + final list = parseDidl(didl( + '영화object.container' + '1화.mkvobject.item.videoItem' + 'http://192.168.0.5:8200/MediaItems/1.mkv' + '노래object.item.audioItem.musicTrack' + 'http://10.0.0.2/s.mp3' + '밖object.item.imageItem.photo' + 'http://8.8.8.8/x.jpg')); + expect(list.map((e) => (e.title, e.isContainer, e.kind)), [('영화', true, 'other'), ('1화.mkv', false, 'video'), ('노래', false, 'audio'), ('밖', false, 'image')]); + expect(list[0].childCount, 12); + expect((list[1].size, list[1].duration, list[1].mime), (123456, const Duration(minutes: 45, seconds: 1), 'video/x-matroska')); + expect(list[1].url, 'http://192.168.0.5:8200/MediaItems/1.mkv'); + expect(list[3].url, isNull, reason: '바깥 주소 (8.8.8.8) 는 따르지 않음'); + expect(dlnaHostAllowed('192.168.1.10'), isTrue); + expect(dlnaHostAllowed('127.0.0.1'), isTrue); + expect(dlnaHostAllowed('100.80.1.2'), isFalse, reason: 'Tailscale 범위 (CGNAT) 는 사설 LAN 이 아님'); + expect(dlnaHostAllowed('example.com'), isFalse); + }); + + test('보낼 주소가 없으면 (Wi-Fi · 유선 아님) 찾지 않고 빈 목록', () async { + expect(await discoverDlna(addresses: const [], wait: Duration.zero), isEmpty); + }); + + test('가짜 서버: Browse (SOAP) 로 목록 · 전체 수 · 이어 읽기 (StartingIndex)', () async { + final starts = []; + final srv = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => srv.close(force: true)); + srv.listen((req) async { + final body = await utf8.decoder.bind(req).join(); + final start = RegExp(r'(\d+)').firstMatch(body)?[1] ?? '?'; + starts.add('${req.headers.value('soapaction')}|$start'); + final result = didl('사진 $startobject.item.imageItem' + 'http://127.0.0.1:${srv.port}/p$start.jpg'); + final esc = result.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + req.response.headers.contentType = ContentType('text', 'xml', charset: 'utf-8'); + req.response.write('' + '$esc' + '12'); + await req.response.close(); + }); + final s = DlnaServer('가짜', 'http://127.0.0.1:${srv.port}/desc.xml', 'http://127.0.0.1:${srv.port}/ctl'); + // 위젯 시험이 있는 파일은 시험 binding 이 HttpClient 를 가짜로 바꾼다 - 진짜 연결로 + await HttpOverrides.runWithHttpOverrides(() async { + final a = await dlnaBrowse(s, objectId: '1&2'); + expect(a.total, 2); + expect(a.entries.single.title, '사진 0'); + expect(a.entries.single.url, 'http://127.0.0.1:${srv.port}/p0.jpg'); + final b = await dlnaBrowse(s, objectId: '1&2', start: 1); + expect(b.entries.single.title, '사진 1'); + expect(starts, ['"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"|0', '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"|1']); + await expectLater(dlnaBrowse(const DlnaServer('밖', 'http://8.8.8.8/', 'http://8.8.8.8/ctl')), throwsA(isA())); + }, _RealHttp()); + }); + + testWidgets('화면: 서버 목록 → 폴더 → 안의 폴더 → ← 로 위 → 서버 목록 · 바깥 주소 항목은 "열 수 없음" · 못 찾으면 안내', (tester) async { + tester.view.physicalSize = const Size(900, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + const nas = DlnaServer('집 NAS', 'http://192.168.0.5/d.xml', 'http://192.168.0.5/ctl'); + await tester.pumpWidget(MaterialApp( + home: DlnaPage( + c: c, + discover: () async => [nas], + browse: (s, id, start) async => switch (id) { + '0' => (entries: [const DlnaEntry('v', '동영상', isContainer: true, childCount: 2)], total: 1), + 'v' => ( + entries: [ + const DlnaEntry('v1', '안쪽', isContainer: true), + const DlnaEntry('x', '밖.mp4', upnpClass: 'object.item.videoItem'), + ], + total: 2 + ), + _ => (entries: [], total: 0), + }, + ), + )); + await tester.pumpAndSettle(); + expect(find.textContaining('보기만 합니다'), findsOneWidget); + await tester.tap(find.text('집 NAS')); + await tester.pumpAndSettle(); + expect(find.text('동영상'), findsOneWidget); + await tester.tap(find.text('동영상')); + await tester.pumpAndSettle(); + expect(find.text('안쪽'), findsOneWidget); + expect(find.text('열 수 없음'), findsOneWidget); + await tester.tap(find.text('밖.mp4')); + await tester.pump(); + expect(find.text('이 항목은 같은 망 밖의 주소라 열지 않습니다'), findsOneWidget); + await tester.tap(find.byTooltip('뒤로')); + await tester.pumpAndSettle(); + expect(find.text('동영상'), findsOneWidget); + await tester.tap(find.byTooltip('뒤로')); + await tester.pumpAndSettle(); + expect(find.text('집 NAS'), findsOneWidget); + + await tester.pumpWidget(MaterialApp(home: DlnaPage(key: UniqueKey(), c: c, discover: () async => const []))); + await tester.pumpAndSettle(); + expect(find.textContaining('미디어 서버를 찾지 못했습니다'), findsOneWidget); + }); +} + +class _RealHttp extends HttpOverrides {}