feat: 浏览器端 P2P 取清单/预览增加进度与超时提示
This commit is contained in:
@@ -365,6 +365,9 @@ h3 { font-family: var(--display); font-size: 15px; margin: 0; font-weight: 600;
|
|||||||
.spin { display: inline-block; width: 11px; height: 11px; border: 1.5px solid var(--line-2); border-top-color: var(--acc); border-radius: 50%; animation: spin 0.7s linear infinite; }
|
.spin { display: inline-block; width: 11px; height: 11px; border: 1.5px solid var(--line-2); border-top-color: var(--acc); border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.bar { width: min(360px, 80%); height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); overflow: hidden; }
|
||||||
|
.bar > i { display: block; height: 100%; background: var(--acc); transition: width 0.25s ease; }
|
||||||
|
|
||||||
.footer { border-top: 1px solid var(--line); padding: 16px 0 26px; color: var(--fg-mute); font-family: var(--mono); font-size: 11px; }
|
.footer { border-top: 1px solid var(--line); padding: 16px 0 26px; color: var(--fg-mute); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
/* 图片预览弹层 */
|
/* 图片预览弹层 */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TorrentFileEntry } from '~/types/api'
|
import type { TorrentFileEntry } from '~/types/api'
|
||||||
import { readFileBytesClient } from '~/utils/clientTorrent'
|
import { formatBytes } from '~/utils/format'
|
||||||
|
import { readFileBytesClient, type FileProgress } from '~/utils/clientTorrent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
infoHash: string
|
infoHash: string
|
||||||
@@ -15,6 +16,8 @@ const emit = defineEmits<{ close: []; 'update:index': [value: number] }>()
|
|||||||
const status = ref<'loading' | 'ok' | 'err'>('loading')
|
const status = ref<'loading' | 'ok' | 'err'>('loading')
|
||||||
const errorText = ref('')
|
const errorText = ref('')
|
||||||
const src = ref('')
|
const src = ref('')
|
||||||
|
const progress = ref(0)
|
||||||
|
const progressText = ref('')
|
||||||
let objectUrl = ''
|
let objectUrl = ''
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
@@ -50,10 +53,18 @@ async function load() {
|
|||||||
revoke()
|
revoke()
|
||||||
src.value = ''
|
src.value = ''
|
||||||
errorText.value = ''
|
errorText.value = ''
|
||||||
|
progress.value = 0
|
||||||
|
progressText.value = ''
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
try {
|
try {
|
||||||
// 优先在用户浏览器里用 WebRTC 从 P2P 网络直接拉取图片字节(不经过 Vercel/Serverless)
|
// 优先在用户浏览器里用 WebRTC 从 P2P 网络直接拉取图片字节(不经过 Vercel/Serverless)
|
||||||
const bytes = await readFileBytesClient(props.magnet, current.value?.path || '', 25000)
|
const bytes = await readFileBytesClient(props.magnet, current.value?.path || '', {
|
||||||
|
timeoutMs: 25000,
|
||||||
|
onProgress: (p: FileProgress) => {
|
||||||
|
progress.value = p.progress
|
||||||
|
progressText.value = p.message
|
||||||
|
}
|
||||||
|
})
|
||||||
const mime = MIME[extOf(current.value?.path || '')] || 'application/octet-stream'
|
const mime = MIME[extOf(current.value?.path || '')] || 'application/octet-stream'
|
||||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: mime }))
|
objectUrl = URL.createObjectURL(new Blob([bytes], { type: mime }))
|
||||||
src.value = objectUrl
|
src.value = objectUrl
|
||||||
@@ -119,8 +130,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lightbox-bd">
|
<div class="lightbox-bd">
|
||||||
<div v-if="status === 'loading'" class="empty">
|
<div v-if="status === 'loading'" class="empty stack sm">
|
||||||
<span class="spin" /> 正在从做种节点拉取这张图片,取决于对方的上传速度…
|
<span class="spin" /> {{ progressText || '正在通过 WebRTC 从做种节点拉取图片…' }}
|
||||||
|
<div v-if="progress > 0" class="bar"><i :style="{ width: (progress * 100).toFixed(0) + '%' }" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
|
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
|
||||||
<div class="note err">{{ errorText }}</div>
|
<div class="note err">{{ errorText }}</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TorrentFileEntry, TorrentView } from '~/types/api'
|
import type { TorrentFileEntry, TorrentView } from '~/types/api'
|
||||||
import { resolveMetadataClient } from '~/utils/clientTorrent'
|
import { resolveMetadataClient, type ResolveProgress } from '~/utils/clientTorrent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
torrent: TorrentView
|
torrent: TorrentView
|
||||||
@@ -12,6 +12,7 @@ const emit = defineEmits<{ resolved: [payload: { files: TorrentFileEntry[]; size
|
|||||||
|
|
||||||
const local = ref<TorrentView>({ ...props.torrent })
|
const local = ref<TorrentView>({ ...props.torrent })
|
||||||
const resolving = ref(false)
|
const resolving = ref(false)
|
||||||
|
const resolveStatus = ref('')
|
||||||
const showTrackers = ref(false)
|
const showTrackers = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -61,6 +62,7 @@ function applyResolved(res: { files: TorrentFileEntry[]; size: number; name: str
|
|||||||
|
|
||||||
async function doResolve() {
|
async function doResolve() {
|
||||||
resolving.value = true
|
resolving.value = true
|
||||||
|
resolveStatus.value = ''
|
||||||
try {
|
try {
|
||||||
// 服务端先尝试(缓存服务取清单,自托管还会走 P2P),成功即返回并落库
|
// 服务端先尝试(缓存服务取清单,自托管还会走 P2P),成功即返回并落库
|
||||||
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
|
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
|
||||||
@@ -72,7 +74,12 @@ async function doResolve() {
|
|||||||
} catch {
|
} catch {
|
||||||
// 服务端无法走 P2P(如 Vercel/Serverless)→ 回退到浏览器端 WebRTC 直接解析
|
// 服务端无法走 P2P(如 Vercel/Serverless)→ 回退到浏览器端 WebRTC 直接解析
|
||||||
try {
|
try {
|
||||||
const meta = await resolveMetadataClient(local.value.magnet, 30000)
|
const meta = await resolveMetadataClient(local.value.magnet, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
onProgress: (p: ResolveProgress) => {
|
||||||
|
resolveStatus.value = p.message
|
||||||
|
}
|
||||||
|
})
|
||||||
applyResolved(meta)
|
applyResolved(meta)
|
||||||
pushToast('ok', '通过浏览器 P2P 解析文件清单', `共 ${meta.files.length} 个文件 / ${formatBytes(meta.size)}`)
|
pushToast('ok', '通过浏览器 P2P 解析文件清单', `共 ${meta.files.length} 个文件 / ${formatBytes(meta.size)}`)
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
@@ -165,6 +172,8 @@ onMounted(() => {
|
|||||||
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
|
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resolving && resolveStatus" class="note warn">{{ resolveStatus }}</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-bd tight">
|
<div class="panel-bd tight">
|
||||||
<FileTable
|
<FileTable
|
||||||
|
|||||||
@@ -57,20 +57,50 @@ function waitForMetadata(torrent: any, timeoutMs: number): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolveProgress {
|
||||||
|
phase: 'connecting' | 'metadata' | 'done'
|
||||||
|
peers: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileProgress {
|
||||||
|
progress: number
|
||||||
|
peers: number
|
||||||
|
downloaded: number
|
||||||
|
length: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveMetadataClient(
|
export async function resolveMetadataClient(
|
||||||
magnet: string,
|
magnet: string,
|
||||||
timeoutMs = 30000
|
opts: { timeoutMs?: number; onProgress?: (p: ResolveProgress) => void } = {}
|
||||||
): Promise<{ name: string; files: { path: string; length: number }[]; size: number }> {
|
): Promise<{ name: string; files: { path: string; length: number }[]; size: number }> {
|
||||||
|
const onProgress = opts.onProgress
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 30000
|
||||||
const WebTorrent = await loadWebTorrent()
|
const WebTorrent = await loadWebTorrent()
|
||||||
const client = new WebTorrent()
|
const client = new WebTorrent()
|
||||||
try {
|
try {
|
||||||
const torrent = client.add(withWsTrackers(magnet))
|
const torrent = client.add(withWsTrackers(magnet))
|
||||||
|
const report = (phase: ResolveProgress['phase'], message?: string) => {
|
||||||
|
const peers = torrent.numPeers ?? 0
|
||||||
|
onProgress?.({
|
||||||
|
phase,
|
||||||
|
peers,
|
||||||
|
message:
|
||||||
|
message ??
|
||||||
|
(peers > 0 ? `已连接 ${peers} 个节点,正在获取元数据…` : '正在通过 WebRTC 寻找 P2P 节点…')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
torrent.on('wire', () => report('connecting'))
|
||||||
|
torrent.on('metadata', () => report('metadata', '已拿到元数据,正在整理文件清单…'))
|
||||||
await waitForMetadata(torrent, timeoutMs)
|
await waitForMetadata(torrent, timeoutMs)
|
||||||
|
report('metadata', '已拿到元数据,正在整理文件清单…')
|
||||||
const files = (torrent.files || []).map((f: any) => ({
|
const files = (torrent.files || []).map((f: any) => ({
|
||||||
path: normPath(String(f.path || f.name || '')),
|
path: normPath(String(f.path || f.name || '')),
|
||||||
length: Number(f.length) || 0
|
length: Number(f.length) || 0
|
||||||
}))
|
}))
|
||||||
if (!files.length) throw new Error('已连上节点,但对方没有返回任何文件信息')
|
if (!files.length) throw new Error('已连上节点,但对方没有返回任何文件信息')
|
||||||
|
onProgress?.({ phase: 'done', peers: torrent.numPeers ?? 0, message: `共 ${files.length} 个文件` })
|
||||||
return { name: String(torrent.name || ''), files, size: files.reduce((s, f) => s + f.length, 0) }
|
return { name: String(torrent.name || ''), files, size: files.reduce((s, f) => s + f.length, 0) }
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -84,8 +114,10 @@ export async function resolveMetadataClient(
|
|||||||
export async function readFileBytesClient(
|
export async function readFileBytesClient(
|
||||||
magnet: string,
|
magnet: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
timeoutMs = 25000
|
opts: { timeoutMs?: number; onProgress?: (p: FileProgress) => void } = {}
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
|
const onProgress = opts.onProgress
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 25000
|
||||||
const WebTorrent = await loadWebTorrent()
|
const WebTorrent = await loadWebTorrent()
|
||||||
const client = new WebTorrent()
|
const client = new WebTorrent()
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +127,31 @@ export async function readFileBytesClient(
|
|||||||
(f: any) => normPath(String(f.path || f.name || '')) === normPath(filePath)
|
(f: any) => normPath(String(f.path || f.name || '')) === normPath(filePath)
|
||||||
)
|
)
|
||||||
if (!target) throw new Error('这个种子里没有该文件,文件清单可能已过期')
|
if (!target) throw new Error('这个种子里没有该文件,文件清单可能已过期')
|
||||||
return await target.arrayBuffer()
|
|
||||||
|
const report = () => {
|
||||||
|
const peers = torrent.numPeers ?? 0
|
||||||
|
const progress = Number(target.progress) || 0
|
||||||
|
onProgress?.({
|
||||||
|
progress,
|
||||||
|
peers,
|
||||||
|
downloaded: Number(target.downloaded) || 0,
|
||||||
|
length: Number(target.length) || 0,
|
||||||
|
message:
|
||||||
|
peers > 0 ? `下载中 ${Math.round(progress * 100)}% · ${peers} 个节点` : '正在连接 P2P 节点…'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
torrent.on('wire', report)
|
||||||
|
if (typeof target.on === 'function') target.on('download', report)
|
||||||
|
report()
|
||||||
|
const ab = await target.arrayBuffer()
|
||||||
|
onProgress?.({
|
||||||
|
progress: 1,
|
||||||
|
peers: torrent.numPeers ?? 0,
|
||||||
|
downloaded: Number(target.length) || 0,
|
||||||
|
length: Number(target.length) || 0,
|
||||||
|
message: '完成'
|
||||||
|
})
|
||||||
|
return ab
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
client.destroy()
|
client.destroy()
|
||||||
|
|||||||
Reference in New Issue
Block a user