optimize transfer logic

This commit is contained in:
2026-10-03 23:06:55 +08:00
parent 5261c2526d
commit aace210b18
5 changed files with 278 additions and 71 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ if (!gotLock) {
unregisterMediaShortcuts() unregisterMediaShortcuts()
destroyTray() destroyTray()
stopFileServer() stopFileServer()
stopMusicServer() stopMusicServer('app-quit')
stopDataServer() stopDataServer()
}) })
} }
+1 -1
View File
@@ -107,7 +107,7 @@ export function registerIpc(): void {
return true return true
}) })
ipcMain.handle(IPC.MUSIC_SERVER_STOP, () => { ipcMain.handle(IPC.MUSIC_SERVER_STOP, () => {
stopMusicServer() stopMusicServer('renderer-stop')
return true return true
}) })
ipcMain.handle(IPC.MUSIC_SERVER_SEND, (_e, cmd: string, body: string) => ipcMain.handle(IPC.MUSIC_SERVER_SEND, (_e, cmd: string, body: string) =>
+120 -23
View File
@@ -20,12 +20,23 @@ import { cleanIosWavFile } from './wifiTransfer'
export type MusicServerEvent = export type MusicServerEvent =
| { type: 'connected' } | { type: 'connected' }
| { type: 'disconnected' } | { type: 'disconnected'; code?: number; reason?: string; wasClean?: boolean }
| { type: 'versionMismatch'; remoteVer: string } | { type: 'versionMismatch'; remoteVer: string }
| { type: 'system'; system: string } | { type: 'system'; system: string }
| { type: 'portUnavailable'; reason: string } | { type: 'portUnavailable'; reason: string }
| { type: 'cmd'; cmd: string; body: string } | { type: 'cmd'; cmd: string; body: string }
function brief(body: string, max = 160): string {
const flat = body.replace(/\s+/g, ' ')
if (flat.length <= max) return flat
return `${flat.slice(0, max)}…(len=${body.length})`
}
function logTransfer(message: string, extra?: unknown): void {
if (extra === undefined) console.log(`[transfer] ${message}`)
else console.log(`[transfer] ${message}`, extra)
}
/** 握手时确保本地 HTTP 文件服务可用(手机靠此端口拉歌) */ /** 握手时确保本地 HTTP 文件服务可用(手机靠此端口拉歌) */
async function ensureFileServerPort(): Promise<{ port: number; error?: string }> { async function ensureFileServerPort(): Promise<{ port: number; error?: string }> {
const status = getFileServerStatus() const status = getFileServerStatus()
@@ -52,48 +63,93 @@ let wss: WebSocketServer | null = null
let socket: WebSocket | null = null let socket: WebSocket | null = null
let phoneSystem = '' let phoneSystem = ''
let lastReadyList: { musicUId: string; baseUrl: string; musicPath: string }[] = [] let lastReadyList: { musicUId: string; baseUrl: string; musicPath: string }[] = []
/** 最近一次业务消息(不含 ping),用于判断 1006 是否因空闲被掐 */
let lastActivity = Date.now()
export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void { export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void {
stopMusicServer() stopMusicServer('restart')
// monkey 并行实例避开开发中的 4388 // monkey 并行实例避开开发中的 4388
const port = process.env.LLMP_MONKEY === '1' ? PORTS.MUSIC_WS + 100 : PORTS.MUSIC_WS const port = process.env.LLMP_MONKEY === '1' ? PORTS.MUSIC_WS + 100 : PORTS.MUSIC_WS
wss = new WebSocketServer({ port, host: '0.0.0.0' }) wss = new WebSocketServer({ port, host: '0.0.0.0' })
wss.on('error', (err) => console.error('[musicServer]', err.message)) logTransfer(`listen 0.0.0.0:${port}`)
wss.on('error', (err) => console.error('[transfer] server error', err))
wss.on('connection', (ws) => { wss.on('connection', (ws, req) => {
const peer = req.socket.remoteAddress || '?'
if (socket && socket !== ws && socket.readyState === WebSocket.OPEN) {
logTransfer(`new connection from ${peer}, closing previous socket`)
try {
socket.close(4000, 'replaced')
} catch (e) {
console.error('[transfer] close previous socket failed', e)
}
}
socket = ws socket = ws
let verified = false let verified = false
phoneSystem = '' phoneSystem = ''
lastReadyList = [] lastReadyList = []
lastActivity = Date.now()
logTransfer(`connected ${peer}`)
onEvent({ type: 'connected' }) onEvent({ type: 'connected' })
// 传歌时指令通道会空等 HTTP 下载,路由器/手机会按空闲掐掉 TCP(关闭码 1006)
const heartbeat = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return
try {
ws.ping()
} catch (e) {
console.error('[transfer] ping failed', peer, e)
}
}, 10000)
ws.on('pong', () => {
logTransfer(`pong ${peer} idle=${Date.now() - lastActivity}ms`)
})
ws.on('message', (raw) => { ws.on('message', (raw) => {
lastActivity = Date.now()
const text = raw.toString()
let command let command
try { try {
command = decodeFtpCmd(raw.toString()) command = decodeFtpCmd(text)
} catch { } catch (e) {
console.error('[transfer] bad message', brief(text), e)
return return
} }
logTransfer(`← ${command.cmd} ${brief(command.body)}`)
switch (command.cmd) { switch (command.cmd) {
case MusicCmd.VERSION: { case MusicCmd.VERSION: {
const compatible = isVersionCompatible(command.body) const compatible = isVersionCompatible(command.body)
verified = true verified = true
ws.send(encodeFtpCmd(MusicCmd.VERSION, TRANS_VER)) try {
ws.send(encodeFtpCmd(MusicCmd.VERSION, TRANS_VER))
logTransfer(`→ ${MusicCmd.VERSION} ${TRANS_VER} compatible=${compatible}`)
} catch (e) {
console.error('[transfer] send version failed', e)
}
if (!compatible) onEvent({ type: 'versionMismatch', remoteVer: command.body }) if (!compatible) onEvent({ type: 'versionMismatch', remoteVer: command.body })
break break
} }
case MusicCmd.SYSTEM: { case MusicCmd.SYSTEM: {
if (!verified) { if (!verified) {
console.error('[transfer] system before version handshake')
onEvent({ type: 'versionMismatch', remoteVer: '?' }) onEvent({ type: 'versionMismatch', remoteVer: '?' })
return return
} }
phoneSystem = (command.body || '').toLowerCase() phoneSystem = (command.body || '').toLowerCase()
onEvent({ type: 'system', system: command.body }) onEvent({ type: 'system', system: command.body })
// 下发 HTTP 文件服务端口(未运行则尝试按配置自动启动) // 下发 HTTP 文件服务端口(未运行则尝试按配置自动启动)
void ensureFileServerPort().then(({ port, error }) => { void ensureFileServerPort().then(({ port: filePort, error }) => {
if (ws.readyState !== WebSocket.OPEN) return if (ws.readyState !== WebSocket.OPEN) {
ws.send(encodeFtpCmd(MusicCmd.PORT, port)) console.error('[transfer] skip port reply, socket not open', ws.readyState)
if (error || port <= 0) { return
}
try {
ws.send(encodeFtpCmd(MusicCmd.PORT, filePort))
logTransfer(`→ ${MusicCmd.PORT} ${filePort}${error ? ` error=${error}` : ''}`)
} catch (e) {
console.error('[transfer] send port failed', e)
}
if (error || filePort <= 0) {
onEvent({ onEvent({
type: 'portUnavailable', type: 'portUnavailable',
reason: error || '本地文件服务端口无效' reason: error || '本地文件服务端口无效'
@@ -105,48 +161,89 @@ export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void {
default: default:
if (command.cmd === MusicCmd.DOWNLOAD_SUCCESS && phoneSystem === 'ios') { if (command.cmd === MusicCmd.DOWNLOAD_SUCCESS && phoneSystem === 'ios') {
const hit = lastReadyList.find((m) => m.musicUId === command.body) const hit = lastReadyList.find((m) => m.musicUId === command.body)
if (hit) cleanIosWavFile(hit) if (hit) {
try {
cleanIosWavFile(hit)
} catch (e) {
console.error('[transfer] clean ios wav failed', command.body, e)
}
}
} }
onEvent({ type: 'cmd', cmd: command.cmd, body: command.body }) onEvent({ type: 'cmd', cmd: command.cmd, body: command.body })
} }
}) })
ws.on('close', () => { ws.on('error', (err) => {
console.error('[transfer] socket error', peer, err)
})
ws.on('close', (code, reason) => {
clearInterval(heartbeat)
const why = reason.toString() || '(empty)'
const current = socket === ws
const idleMs = Date.now() - lastActivity
logTransfer(
`close code=${code} reason=${why} current=${current} peer=${peer} idleMs=${idleMs}`
)
if (!current) return
socket = null socket = null
phoneSystem = '' phoneSystem = ''
lastReadyList = [] lastReadyList = []
onEvent({ type: 'disconnected' }) onEvent({
type: 'disconnected',
code,
reason: why,
wasClean: code === 1000 || code === 1001
})
}) })
}) })
} }
export function sendMusicCmd(cmd: string, body: string): boolean { export function sendMusicCmd(cmd: string, body: string): boolean {
if (!socket || socket.readyState !== WebSocket.OPEN) return false if (!socket || socket.readyState !== WebSocket.OPEN) {
console.error(
`[transfer] send dropped, socket not open cmd=${cmd} state=${socket?.readyState ?? 'null'} body=${brief(body)}`
)
return false
}
if (cmd === MusicCmd.READY) { if (cmd === MusicCmd.READY) {
try { try {
const list = JSON.parse(body) as { musicUId: string; baseUrl: string; musicPath: string }[] const list = JSON.parse(body) as { musicUId: string; baseUrl: string; musicPath: string }[]
lastReadyList = Array.isArray(list) ? list : [] lastReadyList = Array.isArray(list) ? list : []
} catch { } catch (e) {
console.error('[transfer] ready body is not a list', e)
lastReadyList = [] lastReadyList = []
} }
} }
socket.send(encodeFtpCmd(cmd, body)) try {
return true socket.send(encodeFtpCmd(cmd, body))
lastActivity = Date.now()
logTransfer(`→ ${cmd} ${brief(body)}`)
return true
} catch (e) {
console.error(`[transfer] send failed cmd=${cmd}`, e)
return false
}
} }
export function stopMusicServer(): void { export function stopMusicServer(reason = 'stop'): void {
logTransfer(`stop server reason=${reason} hasSocket=${!!socket} hasServer=${!!wss}`)
if (socket) { if (socket) {
try { try {
socket.close() socket.close(1001, reason)
} catch { } catch (e) {
/* ignore */ console.error('[transfer] socket.close failed', e)
} }
socket = null socket = null
} }
if (wss) { if (wss) {
wss.close() try {
wss.close()
} catch (e) {
console.error('[transfer] server.close failed', e)
}
wss = null wss = null
} }
phoneSystem = '' phoneSystem = ''
+45 -11
View File
@@ -1,4 +1,5 @@
import { spawn, ChildProcess } from 'child_process' import { spawn, ChildProcess } from 'child_process'
import { existsSync, unlinkSync } from 'fs'
import ffmpegPath from 'ffmpeg-static' import ffmpegPath from 'ffmpeg-static'
/** /**
@@ -7,6 +8,8 @@ import ffmpegPath from 'ffmpeg-static'
*/ */
let current: ChildProcess | null = null let current: ChildProcess | null = null
/** 终止当前这次转码(杀进程并删掉未完成的 wav) */
let cancelCurrent: (() => void) | null = null
export interface ConvertOptions { export interface ConvertOptions {
input: string input: string
@@ -14,6 +17,14 @@ export interface ConvertOptions {
onProgress?: (percent: number) => void onProgress?: (percent: number) => void
} }
function removePartial(output: string): void {
try {
if (existsSync(output)) unlinkSync(output)
} catch {
/* ignore */
}
}
export function convertFlacToWav(opts: ConvertOptions): Promise<void> { export function convertFlacToWav(opts: ConvertOptions): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!ffmpegPath) { if (!ffmpegPath) {
@@ -21,9 +32,29 @@ export function convertFlacToWav(opts: ConvertOptions): Promise<void> {
return return
} }
stopConvert() stopConvert()
current = spawn(ffmpegPath, ['-y', '-i', opts.input, opts.output]) let aborted = false
let settled = false
const child = spawn(ffmpegPath, ['-y', '-i', opts.input, opts.output])
current = child
const settle = (err?: Error): void => {
if (settled) return
settled = true
if (current === child) current = null
if (cancelCurrent === cancel) cancelCurrent = null
if (err) reject(err)
else resolve()
}
const cancel = (): void => {
aborted = true
try {
child.kill('SIGKILL')
} catch {
/* already exited */
}
}
cancelCurrent = cancel
current.stderr?.on('data', (chunk: Buffer) => { child.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString() const text = chunk.toString()
// 粗略解析进度(ffmpeg 输出 time=xx:xx:xx) // 粗略解析进度(ffmpeg 输出 time=xx:xx:xx)
const match = text.match(/time=(\d+):(\d+):(\d+)/) const match = text.match(/time=(\d+):(\d+):(\d+)/)
@@ -33,18 +64,21 @@ export function convertFlacToWav(opts: ConvertOptions): Promise<void> {
} }
}) })
current.on('error', reject) child.on('error', (err) => {
current.on('close', (code) => { if (aborted) removePartial(opts.output)
current = null settle(aborted ? new Error('转码已取消') : err)
if (code === 0) resolve() })
else reject(new Error(`ffmpeg 退出码 ${code}`)) child.on('close', (code) => {
if (code === 0 && !aborted) {
settle()
return
}
removePartial(opts.output)
settle(new Error(aborted ? '转码已取消' : `ffmpeg 退出码 ${code}`))
}) })
}) })
} }
export function stopConvert(): void { export function stopConvert(): void {
if (current) { cancelCurrent?.()
current.kill('SIGKILL')
current = null
}
} }
+111 -35
View File
@@ -87,25 +87,8 @@ function parseMissingMusicIds(body: string): string[] {
} }
} }
/** 拼本地文件绝对路径(兼容 Windows,供已有 convert.start 使用) */ function isFlac(musicPath: string): boolean {
function mediaAbsPath(root: string, baseUrl: string, musicPath: string): string { return /\.flac$/i.test(musicPath)
const parts = [root, baseUrl, musicPath]
.map((p) => p.replace(/[/\\]+/g, '/').replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
const joined = parts.join('/')
return root.includes('\\') ? joined.replace(/\//g, '\\') : joined
}
/** iOS:用已有 convert API 在 flac 旁生成 wav(不依赖 preload 新增命名空间) */
async function ensureIosWavWithConvert(list: DownloadMusic[]): Promise<void> {
const http = await window.api.http.status()
if (!http?.root) throw new Error('本地曲库根目录未知,请先启动本地文件服务')
for (const m of list) {
if (!/\.flac$/i.test(m.musicPath)) continue
const src = mediaAbsPath(http.root, m.baseUrl, m.musicPath)
const dest = src.replace(/\.flac$/i, '.wav')
await window.api.convert.start(src, dest)
}
} }
export default function Transfer(): JSX.Element { export default function Transfer(): JSX.Element {
@@ -116,6 +99,8 @@ export default function Transfer(): JSX.Element {
const setIpIndex = useLanStore((s) => s.setIpIndex) const setIpIndex = useLanStore((s) => s.setIpIndex)
const loadLan = useLanStore((s) => s.load) const loadLan = useLanStore((s) => s.load)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
/** 本轮传输是否已结束(socket 可能仍开着) */
const [transferDone, setTransferDone] = useState(false)
const [phoneSystem, setPhoneSystem] = useState('') const [phoneSystem, setPhoneSystem] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [overwrite, setOverwrite] = useState(false) const [overwrite, setOverwrite] = useState(false)
@@ -130,6 +115,21 @@ export default function Transfer(): JSX.Element {
const finalListRef = useRef<DownloadMusic[]>([]) const finalListRef = useRef<DownloadMusic[]>([])
const overwriteRef = useRef(false) const overwriteRef = useRef(false)
const phoneSystemRef = useRef('') const phoneSystemRef = useRef('')
/** 断开 / 离开 / 手机停止时递增,进行中的转码与下发看到代际变化后退出 */
const transferGenRef = useRef(0)
/** 是否仍有传歌任务(含手机还在拉文件) */
const transferringRef = useRef(false)
const abortTransferRef = useRef<(notifyPhone: boolean) => void>(() => {})
abortTransferRef.current = (notifyPhone: boolean) => {
const active = transferringRef.current
console.warn(
`[transfer] abort ${JSON.stringify({ notifyPhone, active, gen: transferGenRef.current })}`
)
transferGenRef.current += 1
transferringRef.current = false
void window.api.convert.stop()
if (notifyPhone && active) void window.api.musicServer.send(MusicCmd.STOP, '')
}
const albumById = useMemo(() => { const albumById = useMemo(() => {
const map = new Map<string, Album>() const map = new Map<string, Album>()
for (const a of albums) map.set(a.albumUId, a) for (const a of albums) map.set(a.albumUId, a)
@@ -203,13 +203,24 @@ export default function Transfer(): JSX.Element {
cmd?: string cmd?: string
body?: string body?: string
reason?: string reason?: string
code?: number
wasClean?: boolean
} }
switch (ev.type) { switch (ev.type) {
case 'connected': case 'connected':
setConnected(true) setConnected(true)
setTransferDone(false)
setConnStatus('设备已连接,正在握手…') setConnStatus('设备已连接,正在握手…')
break break
case 'disconnected': case 'disconnected':
console.warn(
`[transfer] disconnected ${JSON.stringify({
code: ev.code,
reason: ev.reason,
wasClean: ev.wasClean
})}`
)
abortTransferRef.current(false)
setConnected(false) setConnected(false)
setPhoneSystem('') setPhoneSystem('')
setConnStatus('等待手机扫码连接…') setConnStatus('等待手机扫码连接…')
@@ -224,6 +235,7 @@ export default function Transfer(): JSX.Element {
break break
case 'system': case 'system':
setPhoneSystem(ev.system || '') setPhoneSystem(ev.system || '')
setTransferDone(false)
setConnStatus(`已连接 ${ev.system} 设备,可开始传输`) setConnStatus(`已连接 ${ev.system} 设备,可开始传输`)
break break
case 'portUnavailable': case 'portUnavailable':
@@ -238,8 +250,9 @@ export default function Transfer(): JSX.Element {
return () => { return () => {
disposed = true disposed = true
abortTransferRef.current(true)
off() off()
window.api.musicServer.stop() void window.api.musicServer.stop()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -248,14 +261,61 @@ export default function Transfer(): JSX.Element {
window.api.store.set('transMusic', [...ids]) window.api.store.set('transMusic', [...ids])
} }
const sendDownloadCmds = (list: DownloadMusic[]): void => { const sendOneDownload = (m: DownloadMusic, isLast: boolean): Promise<boolean> =>
list.forEach((m, i) => { window.api.musicServer.send(
const isLast = i === list.length - 1 MusicCmd.DOWNLOAD,
window.api.musicServer.send( `${m.musicUId}${DOWNLOAD_BODY_SEPARATOR}${isLast}`
MusicCmd.DOWNLOAD, )
`${m.musicUId}${DOWNLOAD_BODY_SEPARATOR}${isLast}`
/**
* iOS:转码队列与传输队列分开。
* 非 flac 立刻进入传输;每首 flac 转完(或已有 wav)就 push 一条 DOWNLOAD,
* 手机开始拉这一首时,下一首继续转。isLast 标在最后一条下发上。
*/
const pipelineIosTransfer = async (list: DownloadMusic[], gen: number): Promise<void> => {
const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list))
if (gen !== transferGenRef.current) return
if (!okReady) {
setJobNotice('发送就绪指令失败,请重新连接手机')
setProgressMode('idle')
return
}
const flacs = list.filter((m) => isFlac(m.musicPath))
const readyNow = list.filter((m) => !isFlac(m.musicPath))
let sent = 0
const push = async (m: DownloadMusic): Promise<boolean> => {
sent += 1
return sendOneDownload(m, sent === list.length)
}
for (const m of readyNow) {
if (gen !== transferGenRef.current) return
if (!(await push(m))) {
setJobNotice('发送下载指令失败,请重新连接手机')
setProgressMode('idle')
return
}
}
for (let i = 0; i < flacs.length; i++) {
if (gen !== transferGenRef.current) return
const m = flacs[i]
setJobNotice(
sent > 0
? `iOS:转换 ${i + 1}/${flacs.length},已开始传输 ${sent} 首…`
: `iOS:转换 ${i + 1}/${flacs.length}…`
) )
}) await window.api.wifi.ensureIosWav([{ baseUrl: m.baseUrl, musicPath: m.musicPath }])
if (gen !== transferGenRef.current) return
if (!(await push(m))) {
setJobNotice('发送下载指令失败,请重新连接手机')
setProgressMode('idle')
return
}
}
if (gen === transferGenRef.current) setJobNotice('正在传输…')
} }
const handlePhoneCmd = async (cmd: string, body: string): Promise<void> => { const handlePhoneCmd = async (cmd: string, body: string): Promise<void> => {
@@ -283,18 +343,21 @@ export default function Transfer(): JSX.Element {
return return
} }
finalListRef.current = list finalListRef.current = list
transferringRef.current = true
setProgressMode('wifi') setProgressMode('wifi')
setProgress({ current: 0, total: list.length }) setProgress({ current: 0, total: list.length })
// iOS 需先把 flac 转成 wav,手机按 .wav URL 下载(用已有 convert.start,避免 preload 未热更新)
if (phoneSystemRef.current.toLowerCase() === 'ios') { if (phoneSystemRef.current.toLowerCase() === 'ios') {
setJobNotice('iOS:正在转换 flac→wav…') const gen = transferGenRef.current
try { try {
await ensureIosWavWithConvert(list) await pipelineIosTransfer(list, gen)
} catch (e) { } catch (e) {
console.error('[transfer] ios pipeline failed', e)
if (gen !== transferGenRef.current) return
window.api.musicServer.send(MusicCmd.STOP, '')
setJobNotice(`iOS 转码失败:${(e as Error).message || '未知错误'}`) setJobNotice(`iOS 转码失败:${(e as Error).message || '未知错误'}`)
setProgressMode('idle') setProgressMode('idle')
return
} }
break
} }
const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list)) const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list))
@@ -303,8 +366,9 @@ export default function Transfer(): JSX.Element {
setProgressMode('idle') setProgressMode('idle')
return return
} }
for (let i = 0; i < list.length; i++) {
sendDownloadCmds(list) void sendOneDownload(list[i], i === list.length - 1)
}
setJobNotice('正在传输…') setJobNotice('正在传输…')
break break
} }
@@ -316,8 +380,16 @@ export default function Transfer(): JSX.Element {
setProgress((p) => ({ ...p, current: Math.min(p.current + 1, p.total) })) setProgress((p) => ({ ...p, current: Math.min(p.current + 1, p.total) }))
break break
case MusicCmd.FINISH: case MusicCmd.FINISH:
case MusicCmd.STOP: abortTransferRef.current(false)
setTransferDone(true)
setJobNotice('传输完成') setJobNotice('传输完成')
setConnStatus('传输完成,连接仍保持,可再次传输')
setProgressMode('idle')
setProgress({ current: 0, total: 0 })
break
case MusicCmd.STOP:
abortTransferRef.current(false)
setJobNotice('传输已停止')
setProgressMode('idle') setProgressMode('idle')
setProgress({ current: 0, total: 0 }) setProgress({ current: 0, total: 0 })
break break
@@ -353,7 +425,9 @@ export default function Transfer(): JSX.Element {
setProgress({ current: 0, total: 0 }) setProgress({ current: 0, total: 0 })
return return
} }
setTransferDone(false)
setJobNotice('已发送歌单,等待手机确认…') setJobNotice('已发送歌单,等待手机确认…')
setConnStatus('正在传输…')
} }
const exportSongs = async (platform: 'android' | 'ios'): Promise<void> => { const exportSongs = async (platform: 'android' | 'ios'): Promise<void> => {
@@ -478,7 +552,9 @@ export default function Transfer(): JSX.Element {
{connected ? <CheckCircle2 size={16} /> : <Wifi size={16} />} {connected ? <CheckCircle2 size={16} /> : <Wifi size={16} />}
</span> </span>
<div> <div>
<p className="text-sm font-semibold">{connected ? '设备已连接' : '扫码连接手机'}</p> <p className="text-sm font-semibold">
{connected ? (transferDone ? '传输完成' : '设备已连接') : '扫码连接手机'}
</p>
<p className="max-w-[220px] text-xs text-[var(--text-secondary)]">{connStatus}</p> <p className="max-w-[220px] text-xs text-[var(--text-secondary)]">{connStatus}</p>
</div> </div>
</div> </div>