fix ios transfer song bug

This commit is contained in:
2026-10-03 15:20:50 +08:00
parent e3ce4aa9fd
commit 34842e2577
24 changed files with 415 additions and 102 deletions
@@ -87,6 +87,8 @@ actual object PlatformUi {
act.finishAffinity()
}
actual fun requestLocalNetworkPermission() = Unit
actual fun setKeepScreenOn(enabled: Boolean) {
val act = AndroidUiHost.activity ?: return
act.runOnUiThread {
@@ -301,6 +301,7 @@ internal val DeStrings: Map<String, String> = mapOf(
"peer_abort" to "Gegenstelle abgebrochen",
"cannot_connect_host" to "Verbindung zu %s fehlgeschlagen",
"failed" to "Fehlgeschlagen",
"transfer_failed_n" to "Übertragung von %s Lied(ern) fehlgeschlagen (zuletzt: %s)",
"handshake_incomplete" to "Handshake unvollständig",
"sent_phone2pc" to "phone2pc gesendet",
"requested_pc2phone" to "pc2phone angefordert",
@@ -301,6 +301,7 @@ internal val EnStrings: Map<String, String> = mapOf(
"peer_abort" to "Peer aborted",
"cannot_connect_host" to "Cannot connect %s",
"failed" to "Failed",
"transfer_failed_n" to "%s song(s) failed to transfer (last: %s)",
"handshake_incomplete" to "Handshake incomplete",
"sent_phone2pc" to "Sent phone2pc",
"requested_pc2phone" to "Requested pc2phone",
@@ -301,6 +301,7 @@ internal val JaStrings: Map<String, String> = mapOf(
"peer_abort" to "相手が中断",
"cannot_connect_host" to "%s に接続できません",
"failed" to "失敗",
"transfer_failed_n" to "%s 曲の転送に失敗しました(最後:%s)",
"handshake_incomplete" to "ハンドシェイク未完了",
"sent_phone2pc" to "phone2pc を送信しました",
"requested_pc2phone" to "pc2phone を要求しました",
@@ -301,6 +301,7 @@ internal val ZhStrings: Map<String, String> = mapOf(
"peer_abort" to "对方中断",
"cannot_connect_host" to "无法连接 %s",
"failed" to "失败",
"transfer_failed_n" to "传输失败 %s 首(最后:%s)",
"handshake_incomplete" to "未完成握手",
"sent_phone2pc" to "已发送 phone2pc",
"requested_pc2phone" to "已请求 pc2phone",
@@ -14,6 +14,7 @@ import io.ktor.http.contentType
import io.ktor.utils.io.exhausted
import io.ktor.utils.io.readAvailable
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
class OssApi(
@@ -65,16 +66,32 @@ class OssApi(
val response = client.get(url) {
header(HttpHeaders.Accept, "*/*")
}
if (response.status.value !in 200..299) return false
if (!fs.writeBegin(dest)) return false
if (response.status.value !in 200..299) {
AppLog.e(DOWNLOAD_TAG, "HTTP ${response.status.value} url=$url")
return false
}
if (!fs.writeBegin(dest)) {
AppLog.e(DOWNLOAD_TAG, "create file failed dest=$dest url=$url")
return false
}
val channel = response.bodyAsChannel()
val buf = ByteArray(64 * 1024)
var written = 0L
while (!channel.exhausted()) {
val n = channel.readAvailable(buf)
if (n <= 0) break
if (!fs.writeAppend(dest, buf, 0, n)) return false
if (!fs.writeAppend(dest, buf, 0, n)) {
AppLog.e(DOWNLOAD_TAG, "write failed offset=$written dest=$dest url=$url")
return false
}
written += n
}
return fs.exists(dest)
val exists = fs.exists(dest)
if (!exists || written == 0L) {
AppLog.e(DOWNLOAD_TAG, "empty or missing file bytes=$written dest=$dest url=$url")
return false
}
return true
}
/**
@@ -97,4 +114,8 @@ class OssApi(
}
}
}
private companion object {
const val DOWNLOAD_TAG = "LLMP-Download"
}
}
@@ -1,14 +1,17 @@
package com.zhushenwudi.lovelivemusicplayer.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.selects.select
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.zhushenwudi.lovelivemusicplayer.Const
@@ -20,7 +23,7 @@ class WsClient(
private val client: HttpClient,
private val json: Json = Json { ignoreUnknownKeys = true },
) {
private var session: WebSocketSession? = null
private var session: DefaultClientWebSocketSession? = null
val isConnected: Boolean get() = session != null
@@ -33,7 +36,9 @@ class WsClient(
close()
session = client.webSocketSession { url("ws://$host:$port") }
true
} catch (_: Exception) {
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Throwable) {
session = null
false
}
@@ -47,20 +52,52 @@ class WsClient(
send(Handshake.buildVersionRequest(localVersion))
}
fun incoming(): Flow<FtpCmd> = flow {
val s = session ?: return@flow
for (frame in s.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
runCatching {
emit(json.decodeFromString(FtpCmd.serializer(), text))
/**
* 收包与命令处理使用两个独立协程。这样下游正在下载大文件时,收包协程仍能立即
* 观察到 Darwin WebSocket 的 incoming channel 关闭,并取消下载与剩余命令队列。
*/
suspend fun receiveUntilClosed(onCommand: suspend (FtpCmd) -> Unit) = coroutineScope {
val current = session ?: return@coroutineScope
val commands = Channel<FtpCmd>(Channel.UNLIMITED)
val readerJob = async {
try {
for (frame in current.incoming) {
if (frame !is Frame.Text) continue
val cmd = runCatching {
json.decodeFromString(FtpCmd.serializer(), frame.readText())
}.getOrNull() ?: continue
commands.send(cmd)
}
} finally {
commands.close()
}
}
val processorJob = async {
for (cmd in commands) onCommand(cmd)
}
try {
select<Unit> {
readerJob.onAwait { }
processorJob.onAwait { }
}
} finally {
commands.cancel()
readerJob.cancel()
processorJob.cancel()
// 旧连接结束时不能清掉一次重连已经创建的新 session。
if (session === current) session = null
}
}
suspend fun close() {
runCatching { session?.close() }
val current = session
session = null
try {
current?.close()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Throwable) {
// 关闭失效 socket 本身也可能抛网络异常,清理操作不应继续向上冒泡。
}
}
}
@@ -11,6 +11,8 @@ expect object PlatformUi {
fun showToast(message: String)
fun openExternalUrl(url: String)
fun exitApp()
/** iOS:进入局域网传输页时提前触发本地网络权限;Android 无需处理。 */
fun requestLocalNetworkPermission()
/** 传输/同步页对齐 Flutter `WakelockPlus`,禁止自动息屏。 */
fun setKeepScreenOn(enabled: Boolean)
}
@@ -1,6 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,6 +19,7 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.TransData
import com.zhushenwudi.lovelivemusicplayer.domain.TransMenu
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
@@ -44,21 +46,39 @@ class DataSyncService(
job?.cancel()
_state.value = SyncUiState(host = host, status = I18n.t("connecting"))
job = scope.launch {
if (!ws.connect(host, TransferChannel.DATA)) {
_state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) }
return@launch
try {
if (!ws.connect(host, TransferChannel.DATA)) {
_state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) }
return@launch
}
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
ws.receiveUntilClosed { handle(it) }
if (_state.value.connected) {
AppLog.w(SYNC_TAG, "websocket closed host=$host")
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
handleSessionFailure("websocket session", error)
} finally {
_state.update {
val keepStatus = it.error != null || !it.connected
it.copy(
connected = false,
verified = false,
status = if (keepStatus) it.status else I18n.t("disconnected"),
)
}
}
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
ws.incoming().collect { handle(it) }
}
}
fun pushPhoneToPc(isCover: Boolean) {
scope.launch {
launchSessionAction("phone to PC") {
if (!_state.value.verified) {
_state.update { it.copy(error = I18n.t("handshake_incomplete")) }
return@launch
return@launchSessionAction
}
val menus = library.getAllMenus()
.filter { if (isCover) true else it.id > 100 }
@@ -81,7 +101,7 @@ class DataSyncService(
}
fun requestPcToPhone() {
scope.launch {
launchSessionAction("PC to phone") {
val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false)
ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data)))
_state.update { it.copy(status = I18n.t("requested_pc2phone")) }
@@ -99,7 +119,14 @@ class DataSyncService(
ProtocolCmd.VERSION -> {
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update { it.copy(error = result.reason, status = I18n.t("version_mismatch")) }
_state.update {
it.copy(
connected = false,
verified = false,
error = result.reason,
status = I18n.t("version_mismatch"),
)
}
ws.close()
return
}
@@ -109,7 +136,13 @@ class DataSyncService(
ProtocolCmd.PHONE2PC -> {
val data = json.decodeFromString<TransData>(cmd.body)
library.replaceLove(data.love)
_state.update { it.copy(status = I18n.t("applied_pc_love")) }
_state.update {
it.copy(
connected = false,
verified = false,
status = I18n.t("applied_pc_love"),
)
}
ws.close()
}
ProtocolCmd.PC2PHONE -> {
@@ -131,10 +164,52 @@ class DataSyncService(
_state.update { it.copy(status = I18n.t("applied_pc_menus")) }
}
ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> {
_state.update { it.copy(status = I18n.t("session_end")) }
_state.update {
it.copy(
connected = false,
verified = false,
status = I18n.t("session_end"),
)
}
ws.close()
}
else -> Unit
}
}
private fun launchSessionAction(
operation: String,
block: suspend () -> Unit,
) {
scope.launch {
try {
block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
handleSessionFailure(operation, error)
}
}
}
private suspend fun handleSessionFailure(operation: String, error: Throwable) {
AppLog.e(SYNC_TAG, "$operation failed host=${_state.value.host}", error)
ws.close()
_state.update {
if (!it.connected && it.error != null) {
it
} else {
it.copy(
connected = false,
verified = false,
status = I18n.t("disconnected"),
error = I18n.t("disconnected"),
)
}
}
}
private companion object {
const val SYNC_TAG = "LLMP-DataSync"
}
}
@@ -1,6 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,8 +19,10 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.DownloadMusic
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
@@ -40,6 +43,7 @@ data class TransferUiState(
val coverKey: String = "",
val progress: Int = 0,
val total: Int = 0,
val failed: Int = 0,
val error: String? = null,
val completed: Boolean = false,
)
@@ -62,11 +66,8 @@ class MusicTransferService(
private var job: Job? = null
private var pending: List<DownloadMusic> = emptyList()
private var systemBody: String = "android"
fun setPlatform(system: String) {
systemBody = system
}
private val failedMusicIds = mutableSetOf<String>()
private val systemBody: String = if (PlatformInfo.isIos) "ios" else "android"
/** 再次打开快传页时清掉上次完成标记,避免一进页就自动返回。 */
fun resetForOpen() {
@@ -76,27 +77,56 @@ class MusicTransferService(
fun connect(host: String) {
job?.cancel()
failedMusicIds.clear()
_state.value = TransferUiState(host = host, status = I18n.t("connecting"))
job = scope.launch {
if (!ws.connect(host, TransferChannel.MUSIC)) {
_state.update {
it.copy(
status = I18n.t("connect_fail"),
error = I18n.t("cannot_connect_host", "$host:${Const.WS_MUSIC_PORT}"),
try {
if (!ws.connect(host, TransferChannel.MUSIC)) {
_state.update {
it.copy(
status = I18n.t("connect_fail"),
error = I18n.t("cannot_connect_host", "$host:${Const.WS_MUSIC_PORT}"),
)
}
return@launch
}
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
ws.receiveUntilClosed { cmd -> handle(cmd) }
if (_state.value.connected) {
AppLog.w(
TRANSFER_TAG,
"websocket closed host=$host current=${_state.value.current}",
)
}
return@launch
}
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
try {
ws.incoming().collect { cmd -> handle(cmd) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
AppLog.e(
TRANSFER_TAG,
"websocket session failed host=$host current=${_state.value.current}",
error,
)
ws.close()
_state.update {
if (!it.connected || it.completed || it.error != null) {
it
} else {
it.copy(
connected = false,
verified = false,
status = I18n.t("disconnected"),
error = I18n.t("disconnected"),
)
}
}
} finally {
_state.update {
val keepStatus = it.completed || it.error != null || !it.connected
it.copy(
connected = false,
verified = false,
status = if (it.completed) it.status else I18n.t("disconnected"),
status = if (keepStatus) it.status else I18n.t("disconnected"),
)
}
}
@@ -119,12 +149,18 @@ class MusicTransferService(
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update {
it.copy(error = result.reason, status = I18n.t("version_mismatch"), verified = false)
it.copy(
connected = false,
error = result.reason,
status = I18n.t("version_mismatch"),
verified = false,
)
}
ws.close()
return
}
_state.update { it.copy(verified = true, status = I18n.t("verified")) }
AppLog.i(TRANSFER_TAG, "handshake verified platform=$systemBody host=${_state.value.host}")
ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody))
}
ProtocolCmd.PORT -> {
@@ -148,8 +184,15 @@ class MusicTransferService(
}
}
pending = need
failedMusicIds.clear()
_state.update {
it.copy(total = need.size, progress = 0, status = I18n.t("pending_n", need.size))
it.copy(
total = need.size,
progress = 0,
failed = 0,
error = null,
status = I18n.t("pending_n", need.size),
)
}
val ids = json.encodeToString(need.map { it.musicUId })
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
@@ -158,8 +201,15 @@ class MusicTransferService(
pending = runCatching {
json.decodeFromString<List<DownloadMusic>>(cmd.body)
}.getOrDefault(emptyList())
failedMusicIds.clear()
_state.update {
it.copy(total = pending.size, progress = 0, status = I18n.t("ready_n", pending.size))
it.copy(
total = pending.size,
progress = 0,
failed = 0,
error = null,
status = I18n.t("ready_n", pending.size),
)
}
}
ProtocolCmd.DOWNLOAD -> {
@@ -168,7 +218,13 @@ class MusicTransferService(
val isLast = parts.getOrNull(1)?.trim().equals("true", ignoreCase = true)
val item = pending.find { it.musicUId == musicUId }
if (item == null) {
AppLog.e(
TRANSFER_TAG,
"download command has unknown uid=$musicUId pending=${pending.size}",
)
markFailed(musicUId)
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
if (isLast) finishTransfer(musicUId)
return
}
_state.update {
@@ -183,34 +239,17 @@ class MusicTransferService(
val ok = downloadOne(item)
if (ok) {
importOne(item)
failedMusicIds.remove(musicUId)
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_SUCCESS.wire, musicUId))
_state.update {
it.copy(progress = it.progress + 1)
it.copy(progress = it.progress + 1, failed = failedMusicIds.size)
}
} else {
markFailed(musicUId)
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
}
if (isLast) {
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
if (ok) {
_state.update {
it.copy(
status = I18n.t("transfer_done"),
completed = true,
connected = false,
)
}
} else {
_state.update {
it.copy(
status = I18n.t("failed"),
error = I18n.t("failed") + " " + item.musicName,
completed = false,
connected = false,
)
}
}
ws.close()
finishTransfer(item.musicName)
}
}
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
@@ -226,21 +265,76 @@ class MusicTransferService(
val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/')
val musicRel = AudioPathUtils.flac2wav(item.musicPath).orEmpty()
val musicDest = root + item.baseUrl.trimStart('/') + musicRel.trimStart('/')
val musicUrl = fileHttpUrl(item.baseUrl, musicRel)
return try {
if (item.coverPath.isNotBlank()) {
runCatching {
fs.writeBytes(coverDest, http.downloadBytes(fileHttpUrl(item.baseUrl, item.coverPath)))
if (fs.exists(coverDest)) {
_state.update { it.copy(coverUrl = "file://$coverDest") }
val coverUrl = fileHttpUrl(item.baseUrl, item.coverPath)
try {
val written = fs.writeBytes(coverDest, http.downloadBytes(coverUrl))
if (!written) {
AppLog.w(TRANSFER_TAG, "cover write failed uid=${item.musicUId} dest=$coverDest")
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
AppLog.w(
TRANSFER_TAG,
"cover download failed uid=${item.musicUId} url=$coverUrl error=${error.message}",
)
}
}
http.downloadToFile(fileHttpUrl(item.baseUrl, musicRel), musicDest, fs)
} catch (_: Throwable) {
http.downloadToFile(musicUrl, musicDest, fs).also { success ->
if (!success) {
AppLog.e(
TRANSFER_TAG,
"music download failed uid=${item.musicUId} name=${item.musicName} " +
"url=$musicUrl dest=$musicDest",
)
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
AppLog.e(
TRANSFER_TAG,
"music download exception uid=${item.musicUId} name=${item.musicName} " +
"url=$musicUrl dest=$musicDest",
error,
)
false
}
}
private fun markFailed(musicUId: String) {
failedMusicIds += musicUId
_state.update { it.copy(failed = failedMusicIds.size) }
}
private suspend fun finishTransfer(lastItemName: String) {
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
val failedCount = failedMusicIds.size
val completed = failedCount == 0
_state.update {
it.copy(
status = if (completed) I18n.t("transfer_done") else I18n.t("failed"),
error = if (completed) {
null
} else {
I18n.t("transfer_failed_n", failedCount, lastItemName)
},
failed = failedCount,
completed = completed,
connected = false,
)
}
AppLog.i(
TRANSFER_TAG,
"transfer finished total=${_state.value.total} success=${_state.value.progress} " +
"failed=$failedCount",
)
ws.close()
}
private fun fileHttpUrl(vararg relativeParts: String): String {
val host = _state.value.host
val port = _state.value.httpPort.ifBlank { "10000" }
@@ -290,4 +384,8 @@ class MusicTransferService(
)
onImported()
}
private companion object {
const val TRANSFER_TAG = "LLMP-Transfer"
}
}
@@ -47,6 +47,10 @@ actual object PlatformUi {
exitProcess(0)
}
actual fun requestLocalNetworkPermission() {
WidgetDataWriter.post("llmpRequestLocalNetworkPermission")
}
actual fun setKeepScreenOn(enabled: Boolean) {
dispatch_async(dispatch_get_main_queue()) {
UIApplication.sharedApplication.idleTimerDisabled = enabled