fix ios transfer song bug
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-17
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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,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,2 +1,3 @@
|
||||
NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です";
|
||||
NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です";
|
||||
NSLocalNetworkUsageDescription = "パソコンに接続して楽曲の転送とデータ同期を行うために、ローカルネットワークへのアクセスが必要です";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
NSCameraUsageDescription = "需要获取摄像头权限扫描二维码";
|
||||
NSPhotoLibraryUsageDescription = "需要读取相册获取图片";
|
||||
NSLocalNetworkUsageDescription = "需要访问本地网络以连接电脑传输歌曲和同步数据";
|
||||
|
||||
+6
-2
@@ -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:
|
||||
|
||||
+2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+50
-13
@@ -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 本身也可能抛网络异常,清理操作不应继续向上冒泡。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+82
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+137
-39
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user