optimize transfer logic
This commit is contained in:
+1
-1
@@ -99,7 +99,7 @@ if (!gotLock) {
|
|||||||
unregisterMediaShortcuts()
|
unregisterMediaShortcuts()
|
||||||
destroyTray()
|
destroyTray()
|
||||||
stopFileServer()
|
stopFileServer()
|
||||||
stopMusicServer()
|
stopMusicServer('app-quit')
|
||||||
stopDataServer()
|
stopDataServer()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user