From 34842e25777b1c00a1a23ab330f777c3cd7a32d1 Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 3 Oct 2026 12:47:48 +0800 Subject: [PATCH] fix ios transfer song bug --- .../lovelivemusicplayer/MainActivity.kt | 1 - .../lovelivemusicplayer/AppContainer.kt | 11 + .../ui/player/FullPlayerScreen.kt | 5 +- .../ui/screens/DrawerFeatureScreens.kt | 34 ++-- iosApp/iosApp.xcodeproj/project.pbxproj | 2 +- iosApp/iosApp/AppDelegate.swift | 7 + iosApp/iosApp/HostBridge.swift | 39 ++++ iosApp/iosApp/Info.plist | 11 +- iosApp/iosApp/de.lproj/InfoPlist.strings | 1 + iosApp/iosApp/en.lproj/InfoPlist.strings | 1 + iosApp/iosApp/ja.lproj/InfoPlist.strings | 1 + iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings | 1 + iosApp/project.yml | 8 +- .../platform/PlatformUi.android.kt | 2 + .../lovelivemusicplayer/i18n/DeStrings.kt | 1 + .../lovelivemusicplayer/i18n/EnStrings.kt | 1 + .../lovelivemusicplayer/i18n/JaStrings.kt | 1 + .../lovelivemusicplayer/i18n/ZhStrings.kt | 1 + .../lovelivemusicplayer/network/OssApi.kt | 29 ++- .../lovelivemusicplayer/network/WsClient.kt | 63 ++++-- .../platform/PlatformUi.kt | 2 + .../transfer/DataSyncService.kt | 99 +++++++-- .../transfer/MusicTransferService.kt | 192 +++++++++++++----- .../platform/PlatformUi.ios.kt | 4 + 24 files changed, 415 insertions(+), 102 deletions(-) diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt index 3022384..3693071 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt @@ -53,7 +53,6 @@ class MainActivity : ComponentActivity() { database = database, player = player!!, ) - AppContainer.musicTransfer.setPlatform("android") } else { player = AppContainer.player } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt index f53fdc3..b38283f 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt index 5202343..c3c67b5 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt @@ -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) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt index c070d4c..5992792 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt @@ -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() } + val readyCoverPages = remember { mutableStateListOf() } val coverUpdates = remember { Channel(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, diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 7242816..868725c 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -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; diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 8f92f50..0f432a4 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -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, diff --git a/iosApp/iosApp/HostBridge.swift b/iosApp/iosApp/HostBridge.swift index b5630f9..dde118c 100644 --- a/iosApp/iosApp/HostBridge.swift +++ b/iosApp/iosApp/HostBridge.swift @@ -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") diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 369443d..b37a533 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -89,18 +89,15 @@ mqqapi timapi mqqopensdkminiapp - mqqopensdkapi mqqopensdkapiV2 mqqopensdkapiV3 mqqopensdkapiV4 - mqqOpensdkSSoLogin mqzoneopensdk mqzoneopensdkapi mqzoneopensdkapi19 mqzoneopensdkapiV2 mqqwpa mqzone - wtloginmqq2 LSRequiresIPhoneOS @@ -119,6 +116,12 @@ NSCameraUsageDescription 需要获取摄像头权限扫描二维码 + NSBonjourServices + + _llmp._tcp + + NSLocalNetworkUsageDescription + 需要访问本地网络以连接电脑传输歌曲和同步数据 NSPhotoLibraryUsageDescription 需要读取相册获取图片 UIApplicationSceneManifest @@ -175,6 +178,6 @@ UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance - + diff --git a/iosApp/iosApp/de.lproj/InfoPlist.strings b/iosApp/iosApp/de.lproj/InfoPlist.strings index 304bb36..650a949 100644 --- a/iosApp/iosApp/de.lproj/InfoPlist.strings +++ b/iosApp/iosApp/de.lproj/InfoPlist.strings @@ -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"; diff --git a/iosApp/iosApp/en.lproj/InfoPlist.strings b/iosApp/iosApp/en.lproj/InfoPlist.strings index acec244..e9a2282 100644 --- a/iosApp/iosApp/en.lproj/InfoPlist.strings +++ b/iosApp/iosApp/en.lproj/InfoPlist.strings @@ -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"; diff --git a/iosApp/iosApp/ja.lproj/InfoPlist.strings b/iosApp/iosApp/ja.lproj/InfoPlist.strings index a22cfb0..58ef295 100644 --- a/iosApp/iosApp/ja.lproj/InfoPlist.strings +++ b/iosApp/iosApp/ja.lproj/InfoPlist.strings @@ -1,2 +1,3 @@ NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です"; NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です"; +NSLocalNetworkUsageDescription = "パソコンに接続して楽曲の転送とデータ同期を行うために、ローカルネットワークへのアクセスが必要です"; diff --git a/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings b/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings index ceb6e25..dcc2a6b 100644 --- a/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings +++ b/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings @@ -1,2 +1,3 @@ NSCameraUsageDescription = "需要获取摄像头权限扫描二维码"; NSPhotoLibraryUsageDescription = "需要读取相册获取图片"; +NSLocalNetworkUsageDescription = "需要访问本地网络以连接电脑传输歌曲和同步数据"; diff --git a/iosApp/project.yml b/iosApp/project.yml index 3a6065d..4459ef4 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -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: diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt index c8a17b7..8f0c43d 100644 --- a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt @@ -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 { diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt index 61882f2..e7f328b 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt @@ -301,6 +301,7 @@ internal val DeStrings: Map = 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", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt index 44ac56c..d7624ed 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt @@ -301,6 +301,7 @@ internal val EnStrings: Map = 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", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt index 1205d14..2eeaafa 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt @@ -301,6 +301,7 @@ internal val JaStrings: Map = 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 を要求しました", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt index e803368..9682bd9 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt @@ -301,6 +301,7 @@ internal val ZhStrings: Map = 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", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt index be52c8a..80f6e24 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt @@ -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" + } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt index 4c9fb46..ee62ffc 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt @@ -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 = 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(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 { + 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 本身也可能抛网络异常,清理操作不应继续向上冒泡。 + } } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt index 39a61f7..4541da5 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt index 7c95228..a4349e3 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt @@ -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(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" + } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt index d3ba6e5..ac35669 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt @@ -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 = emptyList() - private var systemBody: String = "android" - - fun setPlatform(system: String) { - systemBody = system - } + private val failedMusicIds = mutableSetOf() + 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>(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" + } } diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt index 219520c..93b82bf 100644 --- a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt @@ -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