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
@@ -53,7 +53,6 @@ class MainActivity : ComponentActivity() {
database = database,
player = player!!,
)
AppContainer.musicTransfer.setPlatform("android")
} else {
player = AppContainer.player
}
@@ -280,6 +280,17 @@ object AppContainer {
_libraryRevision.value++
}
/** 清空曲库元数据并立即通知仍在组合中的首页列表重新查询。 */
fun clearSongData() {
if (!isInitialized) return
settings.dataVersion = ""
library.clearMusic()
library.clearAlbums()
library.clearArtists()
refreshCarPlayCatalog()
_libraryRevision.value++
}
/** 清空喜欢、历史、歌单和播放队列,并通知首页刷新。 */
fun clearUserData() {
if (!isInitialized) return
@@ -536,7 +536,10 @@ private fun LandscapePlayerBody(
contentAlignment = Alignment.Center,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val side = maxWidth.coerceAtMost(maxHeight).coerceAtLeast(1.dp)
val side = maxWidth
.coerceAtMost(maxHeight)
.coerceAtMost(300.dp)
.coerceAtLeast(1.dp)
PlayerCoverArt(music = current, size = side)
}
}
@@ -71,7 +71,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.ExperimentalResourceApi
@@ -334,10 +333,7 @@ fun SettingsScreen(
onClearSongs = {
scope.launch {
loadingMsg = I18n.t("resetting")
AppContainer.library.clearMusic()
AppContainer.library.clearAlbums()
AppContainer.library.clearArtists()
settings.dataVersion = ""
AppContainer.clearSongData()
loadingMsg = null
PlatformUi.showToast(I18n.t("clean_success"))
}
@@ -470,6 +466,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
var coverAnimationsIdle by remember { mutableStateOf(true) }
var displayedSong by remember { mutableStateOf(state.current) }
var displayedArtist by remember { mutableStateOf(state.artist) }
LaunchedEffect(Unit) { PlatformUi.requestLocalNetworkPermission() }
KeepScreenOn()
var wasCompleted by remember { mutableStateOf(state.completed) }
LaunchedEffect(state.completed) {
@@ -594,9 +591,11 @@ private fun TransferCoverBlock(
onIdleChange: (Boolean) -> Unit,
onDisplayedSongChange: (String, String) -> Unit,
) {
val initialCover = remember { TransferCoverPage(coverKey, coverUrl, song, artist) }
val initialCover = remember {
TransferCoverPage(coverKey, coverUrl, song, artist, pageId = 0)
}
val covers = remember { mutableStateListOf(initialCover) }
val readyCoverKeys = remember { mutableStateListOf<String>() }
val readyCoverPages = remember { mutableStateListOf<Int>() }
val coverUpdates = remember { Channel<TransferCoverPage>(Channel.UNLIMITED) }
val pagerState = rememberPagerState(pageCount = { covers.size })
val latestOnIdleChange by rememberUpdatedState(onIdleChange)
@@ -615,20 +614,19 @@ private fun TransferCoverBlock(
val lastIndex = covers.lastIndex
if (covers[lastIndex].key == nextCover.key) {
// 同一封面只更新网络/本地地址,不改变 Pager 页码。
if (covers[lastIndex] != nextCover) covers[lastIndex] = nextCover
val updatedCover = nextCover.copy(pageId = covers[lastIndex].pageId)
if (covers[lastIndex] != updatedCover) covers[lastIndex] = updatedCover
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
continue
}
covers.add(nextCover)
val targetCover = nextCover.copy(pageId = covers.size)
covers.add(targetCover)
val targetPage = covers.lastIndex
snapshotFlow { pagerState.pageCount }.first { it > targetPage }
// 右侧页图片准备好后再 post 到下一帧启动滚动,避免空 Painter 滑到中央闪白。
withTimeoutOrNull(2_000.milliseconds) {
snapshotFlow {
nextCover.key.isBlank() || nextCover.key in readyCoverKeys
}.first { it }
}
// 每个新页面都等自己的图片首帧准备完成;不能复用相同封面 key 的旧完成状态,
// 也不能超时后强行切换,否则空 Painter 会在滑到中央时闪一下。
snapshotFlow { targetCover.pageId in readyCoverPages }.first { it }
delay(32.milliseconds)
runCatching { pagerState.animateScrollToPage(targetPage) }
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
@@ -652,8 +650,8 @@ private fun TransferCoverBlock(
TransferCoverCircle(
cover = cover,
onReady = {
if (cover.key.isNotBlank() && cover.key !in readyCoverKeys) {
readyCoverKeys.add(cover.key)
if (cover.pageId !in readyCoverPages) {
readyCoverPages.add(cover.pageId)
}
},
)
@@ -665,6 +663,7 @@ private data class TransferCoverPage(
val url: String,
val song: String,
val artist: String,
val pageId: Int = -1,
)
@OptIn(ExperimentalResourceApi::class)
@@ -776,6 +775,7 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
val dark = LocalDarkTheme.current
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
val connected = state.connected
LaunchedEffect(Unit) { PlatformUi.requestLocalNetworkPermission() }
KeepScreenOn()
Scaffold(
containerColor = bg,
+1 -1
View File
@@ -289,7 +289,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nset -euo pipefail\nif [ \"YES\" = \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}\" ]; then\n echo \"Skipping Gradle build (OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES)\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode";
shellScript = "set -euo pipefail\ncd \"$SRCROOT/..\"\nif [ \"YES\" = \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}\" ]; then\n echo \"Kotlin framework was built by the IDE; syncing Compose resources only\"\n ./gradlew -Pcompose.ios.resources.archs=arm64 :composeApp:syncComposeResourcesForIos --rerun\n exit 0\nfi\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode";
};
C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */ = {
isa = PBXShellScriptBuildPhase;
+7
View File
@@ -94,6 +94,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
guard let message = notification.userInfo?["message"] as? String else { return }
HostBridge.shared.showToast(message)
}
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpRequestLocalNetworkPermission"),
object: nil,
queue: .main
) { _ in
HostBridge.shared.requestLocalNetworkPermission()
}
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpPrepareAudioSession"),
object: nil,
+39
View File
@@ -1,9 +1,48 @@
import Network
import UIKit
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
final class HostBridge {
static let shared = HostBridge()
private var localNetworkBrowser: NWBrowser?
private var localNetworkTimeout: DispatchWorkItem?
/// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。
func requestLocalNetworkPermission() {
guard localNetworkBrowser == nil else { return }
let parameters = NWParameters()
parameters.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: "_llmp._tcp", domain: nil),
using: parameters
)
localNetworkBrowser = browser
browser.stateUpdateHandler = { [weak self] state in
switch state {
case .ready, .failed:
self?.stopLocalNetworkPermissionRequest()
default:
break
}
}
browser.start(queue: .main)
let timeout = DispatchWorkItem { [weak self] in
self?.stopLocalNetworkPermissionRequest()
}
localNetworkTimeout = timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: timeout)
}
private func stopLocalNetworkPermissionRequest() {
localNetworkTimeout?.cancel()
localNetworkTimeout = nil
localNetworkBrowser?.stateUpdateHandler = nil
localNetworkBrowser?.cancel()
localNetworkBrowser = nil
}
func handleDeepLink(_ url: String) {
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
+7 -4
View File
@@ -89,18 +89,15 @@
<string>mqqapi</string>
<string>timapi</string>
<string>mqqopensdkminiapp</string>
<string>mqqopensdkapi</string>
<string>mqqopensdkapiV2</string>
<string>mqqopensdkapiV3</string>
<string>mqqopensdkapiV4</string>
<string>mqqOpensdkSSoLogin</string>
<string>mqzoneopensdk</string>
<string>mqzoneopensdkapi</string>
<string>mqzoneopensdkapi19</string>
<string>mqzoneopensdkapiV2</string>
<string>mqqwpa</string>
<string>mqzone</string>
<string>wtloginmqq2</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
@@ -119,6 +116,12 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>需要获取摄像头权限扫描二维码</string>
<key>NSBonjourServices</key>
<array>
<string>_llmp._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问本地网络以连接电脑传输歌曲和同步数据</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要读取相册获取图片</string>
<key>UIApplicationSceneManifest</key>
@@ -175,6 +178,6 @@
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<false/>
</dict>
</plist>
+1
View File
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "Kamerazugriff ist zum Scannen von QR-Codes erforderlich";
NSPhotoLibraryUsageDescription = "Fotobibliothek-Zugriff ist zum Auswählen von Bildern erforderlich";
NSLocalNetworkUsageDescription = "Der Zugriff auf das lokale Netzwerk ist erforderlich, um für Musikübertragungen und die Datensynchronisierung eine Verbindung zu einem Computer herzustellen";
+1
View File
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "Camera access is required to scan QR codes";
NSPhotoLibraryUsageDescription = "Photo library access is required to choose images";
NSLocalNetworkUsageDescription = "Local network access is required to connect to a computer for music transfer and data sync";
+1
View File
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です";
NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です";
NSLocalNetworkUsageDescription = "パソコンに接続して楽曲の転送とデータ同期を行うために、ローカルネットワークへのアクセスが必要です";
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "需要获取摄像头权限扫描二维码";
NSPhotoLibraryUsageDescription = "需要读取相册获取图片";
NSLocalNetworkUsageDescription = "需要访问本地网络以连接电脑传输歌曲和同步数据";
+6 -2
View File
@@ -91,6 +91,9 @@ targets:
- UIInterfaceOrientationLandscapeRight
NSCameraUsageDescription: 需要获取摄像头权限扫描二维码
NSPhotoLibraryUsageDescription: 需要读取相册获取图片
NSLocalNetworkUsageDescription: 需要访问本地网络以连接电脑传输歌曲和同步数据
NSBonjourServices:
- _llmp._tcp
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
NSAllowsArbitraryLoads: true
@@ -206,11 +209,12 @@ targets:
basedOnDependencyAnalysis: false
script: |
set -euo pipefail
cd "$SRCROOT/.."
if [ "YES" = "${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}" ]; then
echo "Skipping Gradle build (OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES)"
echo "Kotlin framework was built by the IDE; syncing Compose resources only"
./gradlew -Pcompose.ios.resources.archs=arm64 :composeApp:syncComposeResourcesForIos --rerun
exit 0
fi
cd "$SRCROOT/.."
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
WidgetExtension:
@@ -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
}
return fs.exists(dest)
written += n
}
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 {
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.incoming().collect { handle(it) }
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"),
)
}
}
}
}
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,8 +77,10 @@ class MusicTransferService(
fun connect(host: String) {
job?.cancel()
failedMusicIds.clear()
_state.value = TransferUiState(host = host, status = I18n.t("connecting"))
job = scope.launch {
try {
if (!ws.connect(host, TransferChannel.MUSIC)) {
_state.update {
it.copy(
@@ -89,14 +92,41 @@ class MusicTransferService(
}
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
try {
ws.incoming().collect { cmd -> handle(cmd) }
} finally {
ws.receiveUntilClosed { cmd -> handle(cmd) }
if (_state.value.connected) {
AppLog.w(
TRANSFER_TAG,
"websocket closed host=$host current=${_state.value.current}",
)
}
} 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 = if (it.completed) it.status else I18n.t("disconnected"),
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 (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(musicUrl, musicDest, fs).also { success ->
if (!success) {
AppLog.e(
TRANSFER_TAG,
"music download failed uid=${item.musicUId} name=${item.musicName} " +
"url=$musicUrl dest=$musicDest",
)
}
http.downloadToFile(fileHttpUrl(item.baseUrl, musicRel), musicDest, fs)
} catch (_: Throwable) {
}
} 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