fix ios transfer song bug
This commit is contained in:
@@ -53,7 +53,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
database = database,
|
database = database,
|
||||||
player = player!!,
|
player = player!!,
|
||||||
)
|
)
|
||||||
AppContainer.musicTransfer.setPlatform("android")
|
|
||||||
} else {
|
} else {
|
||||||
player = AppContainer.player
|
player = AppContainer.player
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,17 @@ object AppContainer {
|
|||||||
_libraryRevision.value++
|
_libraryRevision.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清空曲库元数据并立即通知仍在组合中的首页列表重新查询。 */
|
||||||
|
fun clearSongData() {
|
||||||
|
if (!isInitialized) return
|
||||||
|
settings.dataVersion = ""
|
||||||
|
library.clearMusic()
|
||||||
|
library.clearAlbums()
|
||||||
|
library.clearArtists()
|
||||||
|
refreshCarPlayCatalog()
|
||||||
|
_libraryRevision.value++
|
||||||
|
}
|
||||||
|
|
||||||
/** 清空喜欢、历史、歌单和播放队列,并通知首页刷新。 */
|
/** 清空喜欢、历史、歌单和播放队列,并通知首页刷新。 */
|
||||||
fun clearUserData() {
|
fun clearUserData() {
|
||||||
if (!isInitialized) return
|
if (!isInitialized) return
|
||||||
|
|||||||
+4
-1
@@ -536,7 +536,10 @@ private fun LandscapePlayerBody(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
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)
|
PlayerCoverArt(music = current, size = side)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -71,7 +71,6 @@ import kotlinx.coroutines.channels.Channel
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||||
@@ -334,10 +333,7 @@ fun SettingsScreen(
|
|||||||
onClearSongs = {
|
onClearSongs = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
loadingMsg = I18n.t("resetting")
|
loadingMsg = I18n.t("resetting")
|
||||||
AppContainer.library.clearMusic()
|
AppContainer.clearSongData()
|
||||||
AppContainer.library.clearAlbums()
|
|
||||||
AppContainer.library.clearArtists()
|
|
||||||
settings.dataVersion = ""
|
|
||||||
loadingMsg = null
|
loadingMsg = null
|
||||||
PlatformUi.showToast(I18n.t("clean_success"))
|
PlatformUi.showToast(I18n.t("clean_success"))
|
||||||
}
|
}
|
||||||
@@ -470,6 +466,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
|
|||||||
var coverAnimationsIdle by remember { mutableStateOf(true) }
|
var coverAnimationsIdle by remember { mutableStateOf(true) }
|
||||||
var displayedSong by remember { mutableStateOf(state.current) }
|
var displayedSong by remember { mutableStateOf(state.current) }
|
||||||
var displayedArtist by remember { mutableStateOf(state.artist) }
|
var displayedArtist by remember { mutableStateOf(state.artist) }
|
||||||
|
LaunchedEffect(Unit) { PlatformUi.requestLocalNetworkPermission() }
|
||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
var wasCompleted by remember { mutableStateOf(state.completed) }
|
var wasCompleted by remember { mutableStateOf(state.completed) }
|
||||||
LaunchedEffect(state.completed) {
|
LaunchedEffect(state.completed) {
|
||||||
@@ -594,9 +591,11 @@ private fun TransferCoverBlock(
|
|||||||
onIdleChange: (Boolean) -> Unit,
|
onIdleChange: (Boolean) -> Unit,
|
||||||
onDisplayedSongChange: (String, String) -> 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 covers = remember { mutableStateListOf(initialCover) }
|
||||||
val readyCoverKeys = remember { mutableStateListOf<String>() }
|
val readyCoverPages = remember { mutableStateListOf<Int>() }
|
||||||
val coverUpdates = remember { Channel<TransferCoverPage>(Channel.UNLIMITED) }
|
val coverUpdates = remember { Channel<TransferCoverPage>(Channel.UNLIMITED) }
|
||||||
val pagerState = rememberPagerState(pageCount = { covers.size })
|
val pagerState = rememberPagerState(pageCount = { covers.size })
|
||||||
val latestOnIdleChange by rememberUpdatedState(onIdleChange)
|
val latestOnIdleChange by rememberUpdatedState(onIdleChange)
|
||||||
@@ -615,20 +614,19 @@ private fun TransferCoverBlock(
|
|||||||
val lastIndex = covers.lastIndex
|
val lastIndex = covers.lastIndex
|
||||||
if (covers[lastIndex].key == nextCover.key) {
|
if (covers[lastIndex].key == nextCover.key) {
|
||||||
// 同一封面只更新网络/本地地址,不改变 Pager 页码。
|
// 同一封面只更新网络/本地地址,不改变 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)
|
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
covers.add(nextCover)
|
val targetCover = nextCover.copy(pageId = covers.size)
|
||||||
|
covers.add(targetCover)
|
||||||
val targetPage = covers.lastIndex
|
val targetPage = covers.lastIndex
|
||||||
snapshotFlow { pagerState.pageCount }.first { it > targetPage }
|
snapshotFlow { pagerState.pageCount }.first { it > targetPage }
|
||||||
// 右侧页图片准备好后再 post 到下一帧启动滚动,避免空 Painter 滑到中央闪白。
|
// 每个新页面都等自己的图片首帧准备完成;不能复用相同封面 key 的旧完成状态,
|
||||||
withTimeoutOrNull(2_000.milliseconds) {
|
// 也不能超时后强行切换,否则空 Painter 会在滑到中央时闪一下。
|
||||||
snapshotFlow {
|
snapshotFlow { targetCover.pageId in readyCoverPages }.first { it }
|
||||||
nextCover.key.isBlank() || nextCover.key in readyCoverKeys
|
|
||||||
}.first { it }
|
|
||||||
}
|
|
||||||
delay(32.milliseconds)
|
delay(32.milliseconds)
|
||||||
runCatching { pagerState.animateScrollToPage(targetPage) }
|
runCatching { pagerState.animateScrollToPage(targetPage) }
|
||||||
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
|
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
|
||||||
@@ -652,8 +650,8 @@ private fun TransferCoverBlock(
|
|||||||
TransferCoverCircle(
|
TransferCoverCircle(
|
||||||
cover = cover,
|
cover = cover,
|
||||||
onReady = {
|
onReady = {
|
||||||
if (cover.key.isNotBlank() && cover.key !in readyCoverKeys) {
|
if (cover.pageId !in readyCoverPages) {
|
||||||
readyCoverKeys.add(cover.key)
|
readyCoverPages.add(cover.pageId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -665,6 +663,7 @@ private data class TransferCoverPage(
|
|||||||
val url: String,
|
val url: String,
|
||||||
val song: String,
|
val song: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
|
val pageId: Int = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalResourceApi::class)
|
@OptIn(ExperimentalResourceApi::class)
|
||||||
@@ -776,6 +775,7 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
|
|||||||
val dark = LocalDarkTheme.current
|
val dark = LocalDarkTheme.current
|
||||||
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
|
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
|
||||||
val connected = state.connected
|
val connected = state.connected
|
||||||
|
LaunchedEffect(Unit) { PlatformUi.requestLocalNetworkPermission() }
|
||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = bg,
|
containerColor = bg,
|
||||||
|
|||||||
@@ -289,7 +289,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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 */ = {
|
C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
guard let message = notification.userInfo?["message"] as? String else { return }
|
guard let message = notification.userInfo?["message"] as? String else { return }
|
||||||
HostBridge.shared.showToast(message)
|
HostBridge.shared.showToast(message)
|
||||||
}
|
}
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: Notification.Name("llmpRequestLocalNetworkPermission"),
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
HostBridge.shared.requestLocalNetworkPermission()
|
||||||
|
}
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: Notification.Name("llmpPrepareAudioSession"),
|
forName: Notification.Name("llmpPrepareAudioSession"),
|
||||||
object: nil,
|
object: nil,
|
||||||
|
|||||||
@@ -1,9 +1,48 @@
|
|||||||
|
import Network
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
|
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
|
||||||
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
|
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
|
||||||
final class HostBridge {
|
final class HostBridge {
|
||||||
static let shared = 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) {
|
func handleDeepLink(_ url: String) {
|
||||||
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
|
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
|
||||||
|
|||||||
@@ -89,18 +89,15 @@
|
|||||||
<string>mqqapi</string>
|
<string>mqqapi</string>
|
||||||
<string>timapi</string>
|
<string>timapi</string>
|
||||||
<string>mqqopensdkminiapp</string>
|
<string>mqqopensdkminiapp</string>
|
||||||
<string>mqqopensdkapi</string>
|
|
||||||
<string>mqqopensdkapiV2</string>
|
<string>mqqopensdkapiV2</string>
|
||||||
<string>mqqopensdkapiV3</string>
|
<string>mqqopensdkapiV3</string>
|
||||||
<string>mqqopensdkapiV4</string>
|
<string>mqqopensdkapiV4</string>
|
||||||
<string>mqqOpensdkSSoLogin</string>
|
|
||||||
<string>mqzoneopensdk</string>
|
<string>mqzoneopensdk</string>
|
||||||
<string>mqzoneopensdkapi</string>
|
<string>mqzoneopensdkapi</string>
|
||||||
<string>mqzoneopensdkapi19</string>
|
<string>mqzoneopensdkapi19</string>
|
||||||
<string>mqzoneopensdkapiV2</string>
|
<string>mqzoneopensdkapiV2</string>
|
||||||
<string>mqqwpa</string>
|
<string>mqqwpa</string>
|
||||||
<string>mqzone</string>
|
<string>mqzone</string>
|
||||||
<string>wtloginmqq2</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -119,6 +116,12 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>需要获取摄像头权限扫描二维码</string>
|
<string>需要获取摄像头权限扫描二维码</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_llmp._tcp</string>
|
||||||
|
</array>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>需要访问本地网络以连接电脑传输歌曲和同步数据</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>需要读取相册获取图片</string>
|
<string>需要读取相册获取图片</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
@@ -175,6 +178,6 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
NSCameraUsageDescription = "Kamerazugriff ist zum Scannen von QR-Codes erforderlich";
|
NSCameraUsageDescription = "Kamerazugriff ist zum Scannen von QR-Codes erforderlich";
|
||||||
NSPhotoLibraryUsageDescription = "Fotobibliothek-Zugriff ist zum Auswählen von Bildern 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";
|
NSCameraUsageDescription = "Camera access is required to scan QR codes";
|
||||||
NSPhotoLibraryUsageDescription = "Photo library access is required to choose images";
|
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コードをスキャンするためにカメラへのアクセスが必要です";
|
NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です";
|
||||||
NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です";
|
NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です";
|
||||||
|
NSLocalNetworkUsageDescription = "パソコンに接続して楽曲の転送とデータ同期を行うために、ローカルネットワークへのアクセスが必要です";
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
NSCameraUsageDescription = "需要获取摄像头权限扫描二维码";
|
NSCameraUsageDescription = "需要获取摄像头权限扫描二维码";
|
||||||
NSPhotoLibraryUsageDescription = "需要读取相册获取图片";
|
NSPhotoLibraryUsageDescription = "需要读取相册获取图片";
|
||||||
|
NSLocalNetworkUsageDescription = "需要访问本地网络以连接电脑传输歌曲和同步数据";
|
||||||
|
|||||||
+6
-2
@@ -91,6 +91,9 @@ targets:
|
|||||||
- UIInterfaceOrientationLandscapeRight
|
- UIInterfaceOrientationLandscapeRight
|
||||||
NSCameraUsageDescription: 需要获取摄像头权限扫描二维码
|
NSCameraUsageDescription: 需要获取摄像头权限扫描二维码
|
||||||
NSPhotoLibraryUsageDescription: 需要读取相册获取图片
|
NSPhotoLibraryUsageDescription: 需要读取相册获取图片
|
||||||
|
NSLocalNetworkUsageDescription: 需要访问本地网络以连接电脑传输歌曲和同步数据
|
||||||
|
NSBonjourServices:
|
||||||
|
- _llmp._tcp
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
@@ -206,11 +209,12 @@ targets:
|
|||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
cd "$SRCROOT/.."
|
||||||
if [ "YES" = "${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}" ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
cd "$SRCROOT/.."
|
|
||||||
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
|
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
|
||||||
|
|
||||||
WidgetExtension:
|
WidgetExtension:
|
||||||
|
|||||||
+2
@@ -87,6 +87,8 @@ actual object PlatformUi {
|
|||||||
act.finishAffinity()
|
act.finishAffinity()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun requestLocalNetworkPermission() = Unit
|
||||||
|
|
||||||
actual fun setKeepScreenOn(enabled: Boolean) {
|
actual fun setKeepScreenOn(enabled: Boolean) {
|
||||||
val act = AndroidUiHost.activity ?: return
|
val act = AndroidUiHost.activity ?: return
|
||||||
act.runOnUiThread {
|
act.runOnUiThread {
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ internal val DeStrings: Map<String, String> = mapOf(
|
|||||||
"peer_abort" to "Gegenstelle abgebrochen",
|
"peer_abort" to "Gegenstelle abgebrochen",
|
||||||
"cannot_connect_host" to "Verbindung zu %s fehlgeschlagen",
|
"cannot_connect_host" to "Verbindung zu %s fehlgeschlagen",
|
||||||
"failed" to "Fehlgeschlagen",
|
"failed" to "Fehlgeschlagen",
|
||||||
|
"transfer_failed_n" to "Übertragung von %s Lied(ern) fehlgeschlagen (zuletzt: %s)",
|
||||||
"handshake_incomplete" to "Handshake unvollständig",
|
"handshake_incomplete" to "Handshake unvollständig",
|
||||||
"sent_phone2pc" to "phone2pc gesendet",
|
"sent_phone2pc" to "phone2pc gesendet",
|
||||||
"requested_pc2phone" to "pc2phone angefordert",
|
"requested_pc2phone" to "pc2phone angefordert",
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ internal val EnStrings: Map<String, String> = mapOf(
|
|||||||
"peer_abort" to "Peer aborted",
|
"peer_abort" to "Peer aborted",
|
||||||
"cannot_connect_host" to "Cannot connect %s",
|
"cannot_connect_host" to "Cannot connect %s",
|
||||||
"failed" to "Failed",
|
"failed" to "Failed",
|
||||||
|
"transfer_failed_n" to "%s song(s) failed to transfer (last: %s)",
|
||||||
"handshake_incomplete" to "Handshake incomplete",
|
"handshake_incomplete" to "Handshake incomplete",
|
||||||
"sent_phone2pc" to "Sent phone2pc",
|
"sent_phone2pc" to "Sent phone2pc",
|
||||||
"requested_pc2phone" to "Requested pc2phone",
|
"requested_pc2phone" to "Requested pc2phone",
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ internal val JaStrings: Map<String, String> = mapOf(
|
|||||||
"peer_abort" to "相手が中断",
|
"peer_abort" to "相手が中断",
|
||||||
"cannot_connect_host" to "%s に接続できません",
|
"cannot_connect_host" to "%s に接続できません",
|
||||||
"failed" to "失敗",
|
"failed" to "失敗",
|
||||||
|
"transfer_failed_n" to "%s 曲の転送に失敗しました(最後:%s)",
|
||||||
"handshake_incomplete" to "ハンドシェイク未完了",
|
"handshake_incomplete" to "ハンドシェイク未完了",
|
||||||
"sent_phone2pc" to "phone2pc を送信しました",
|
"sent_phone2pc" to "phone2pc を送信しました",
|
||||||
"requested_pc2phone" to "pc2phone を要求しました",
|
"requested_pc2phone" to "pc2phone を要求しました",
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ internal val ZhStrings: Map<String, String> = mapOf(
|
|||||||
"peer_abort" to "对方中断",
|
"peer_abort" to "对方中断",
|
||||||
"cannot_connect_host" to "无法连接 %s",
|
"cannot_connect_host" to "无法连接 %s",
|
||||||
"failed" to "失败",
|
"failed" to "失败",
|
||||||
|
"transfer_failed_n" to "传输失败 %s 首(最后:%s)",
|
||||||
"handshake_incomplete" to "未完成握手",
|
"handshake_incomplete" to "未完成握手",
|
||||||
"sent_phone2pc" to "已发送 phone2pc",
|
"sent_phone2pc" to "已发送 phone2pc",
|
||||||
"requested_pc2phone" to "已请求 pc2phone",
|
"requested_pc2phone" to "已请求 pc2phone",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import io.ktor.http.contentType
|
|||||||
import io.ktor.utils.io.exhausted
|
import io.ktor.utils.io.exhausted
|
||||||
import io.ktor.utils.io.readAvailable
|
import io.ktor.utils.io.readAvailable
|
||||||
import com.zhushenwudi.lovelivemusicplayer.Const
|
import com.zhushenwudi.lovelivemusicplayer.Const
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
|
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
|
||||||
|
|
||||||
class OssApi(
|
class OssApi(
|
||||||
@@ -65,16 +66,32 @@ class OssApi(
|
|||||||
val response = client.get(url) {
|
val response = client.get(url) {
|
||||||
header(HttpHeaders.Accept, "*/*")
|
header(HttpHeaders.Accept, "*/*")
|
||||||
}
|
}
|
||||||
if (response.status.value !in 200..299) return false
|
if (response.status.value !in 200..299) {
|
||||||
if (!fs.writeBegin(dest)) return false
|
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 channel = response.bodyAsChannel()
|
||||||
val buf = ByteArray(64 * 1024)
|
val buf = ByteArray(64 * 1024)
|
||||||
|
var written = 0L
|
||||||
while (!channel.exhausted()) {
|
while (!channel.exhausted()) {
|
||||||
val n = channel.readAvailable(buf)
|
val n = channel.readAvailable(buf)
|
||||||
if (n <= 0) break
|
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
|
package com.zhushenwudi.lovelivemusicplayer.network
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||||
import io.ktor.client.plugins.websocket.webSocketSession
|
import io.ktor.client.plugins.websocket.webSocketSession
|
||||||
import io.ktor.client.request.url
|
import io.ktor.client.request.url
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.WebSocketSession
|
|
||||||
import io.ktor.websocket.close
|
import io.ktor.websocket.close
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.flow
|
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.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import com.zhushenwudi.lovelivemusicplayer.Const
|
import com.zhushenwudi.lovelivemusicplayer.Const
|
||||||
@@ -20,7 +23,7 @@ class WsClient(
|
|||||||
private val client: HttpClient,
|
private val client: HttpClient,
|
||||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||||
) {
|
) {
|
||||||
private var session: WebSocketSession? = null
|
private var session: DefaultClientWebSocketSession? = null
|
||||||
|
|
||||||
val isConnected: Boolean get() = session != null
|
val isConnected: Boolean get() = session != null
|
||||||
|
|
||||||
@@ -33,7 +36,9 @@ class WsClient(
|
|||||||
close()
|
close()
|
||||||
session = client.webSocketSession { url("ws://$host:$port") }
|
session = client.webSocketSession { url("ws://$host:$port") }
|
||||||
true
|
true
|
||||||
} catch (_: Exception) {
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (_: Throwable) {
|
||||||
session = null
|
session = null
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -47,20 +52,52 @@ class WsClient(
|
|||||||
send(Handshake.buildVersionRequest(localVersion))
|
send(Handshake.buildVersionRequest(localVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun incoming(): Flow<FtpCmd> = flow {
|
/**
|
||||||
val s = session ?: return@flow
|
* 收包与命令处理使用两个独立协程。这样下游正在下载大文件时,收包协程仍能立即
|
||||||
for (frame in s.incoming) {
|
* 观察到 Darwin WebSocket 的 incoming channel 关闭,并取消下载与剩余命令队列。
|
||||||
if (frame is Frame.Text) {
|
*/
|
||||||
val text = frame.readText()
|
suspend fun receiveUntilClosed(onCommand: suspend (FtpCmd) -> Unit) = coroutineScope {
|
||||||
runCatching {
|
val current = session ?: return@coroutineScope
|
||||||
emit(json.decodeFromString(FtpCmd.serializer(), text))
|
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() {
|
suspend fun close() {
|
||||||
runCatching { session?.close() }
|
val current = session
|
||||||
session = null
|
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 showToast(message: String)
|
||||||
fun openExternalUrl(url: String)
|
fun openExternalUrl(url: String)
|
||||||
fun exitApp()
|
fun exitApp()
|
||||||
|
/** iOS:进入局域网传输页时提前触发本地网络权限;Android 无需处理。 */
|
||||||
|
fun requestLocalNetworkPermission()
|
||||||
/** 传输/同步页对齐 Flutter `WakelockPlus`,禁止自动息屏。 */
|
/** 传输/同步页对齐 Flutter `WakelockPlus`,禁止自动息屏。 */
|
||||||
fun setKeepScreenOn(enabled: Boolean)
|
fun setKeepScreenOn(enabled: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-7
@@ -1,6 +1,7 @@
|
|||||||
package com.zhushenwudi.lovelivemusicplayer.transfer
|
package com.zhushenwudi.lovelivemusicplayer.transfer
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.TransData
|
||||||
import com.zhushenwudi.lovelivemusicplayer.domain.TransMenu
|
import com.zhushenwudi.lovelivemusicplayer.domain.TransMenu
|
||||||
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
|
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
|
||||||
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
|
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
|
||||||
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
|
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
|
||||||
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
|
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
|
||||||
@@ -44,21 +46,39 @@ class DataSyncService(
|
|||||||
job?.cancel()
|
job?.cancel()
|
||||||
_state.value = SyncUiState(host = host, status = I18n.t("connecting"))
|
_state.value = SyncUiState(host = host, status = I18n.t("connecting"))
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
|
try {
|
||||||
if (!ws.connect(host, TransferChannel.DATA)) {
|
if (!ws.connect(host, TransferChannel.DATA)) {
|
||||||
_state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) }
|
_state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
|
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
|
||||||
ws.sendVersion()
|
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) {
|
fun pushPhoneToPc(isCover: Boolean) {
|
||||||
scope.launch {
|
launchSessionAction("phone to PC") {
|
||||||
if (!_state.value.verified) {
|
if (!_state.value.verified) {
|
||||||
_state.update { it.copy(error = I18n.t("handshake_incomplete")) }
|
_state.update { it.copy(error = I18n.t("handshake_incomplete")) }
|
||||||
return@launch
|
return@launchSessionAction
|
||||||
}
|
}
|
||||||
val menus = library.getAllMenus()
|
val menus = library.getAllMenus()
|
||||||
.filter { if (isCover) true else it.id > 100 }
|
.filter { if (isCover) true else it.id > 100 }
|
||||||
@@ -81,7 +101,7 @@ class DataSyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun requestPcToPhone() {
|
fun requestPcToPhone() {
|
||||||
scope.launch {
|
launchSessionAction("PC to phone") {
|
||||||
val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false)
|
val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false)
|
||||||
ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data)))
|
ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data)))
|
||||||
_state.update { it.copy(status = I18n.t("requested_pc2phone")) }
|
_state.update { it.copy(status = I18n.t("requested_pc2phone")) }
|
||||||
@@ -99,7 +119,14 @@ class DataSyncService(
|
|||||||
ProtocolCmd.VERSION -> {
|
ProtocolCmd.VERSION -> {
|
||||||
val result = Handshake.verify(cmd, Const.TRANS_VER)
|
val result = Handshake.verify(cmd, Const.TRANS_VER)
|
||||||
if (!result.ok) {
|
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()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,7 +136,13 @@ class DataSyncService(
|
|||||||
ProtocolCmd.PHONE2PC -> {
|
ProtocolCmd.PHONE2PC -> {
|
||||||
val data = json.decodeFromString<TransData>(cmd.body)
|
val data = json.decodeFromString<TransData>(cmd.body)
|
||||||
library.replaceLove(data.love)
|
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()
|
ws.close()
|
||||||
}
|
}
|
||||||
ProtocolCmd.PC2PHONE -> {
|
ProtocolCmd.PC2PHONE -> {
|
||||||
@@ -131,10 +164,52 @@ class DataSyncService(
|
|||||||
_state.update { it.copy(status = I18n.t("applied_pc_menus")) }
|
_state.update { it.copy(status = I18n.t("applied_pc_menus")) }
|
||||||
}
|
}
|
||||||
ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> {
|
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()
|
ws.close()
|
||||||
}
|
}
|
||||||
else -> Unit
|
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
|
package com.zhushenwudi.lovelivemusicplayer.transfer
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.DownloadMusic
|
||||||
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
|
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
|
||||||
import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
|
||||||
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
|
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
|
||||||
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
|
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
|
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
|
||||||
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
|
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
|
||||||
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
|
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
|
||||||
@@ -40,6 +43,7 @@ data class TransferUiState(
|
|||||||
val coverKey: String = "",
|
val coverKey: String = "",
|
||||||
val progress: Int = 0,
|
val progress: Int = 0,
|
||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
|
val failed: Int = 0,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val completed: Boolean = false,
|
val completed: Boolean = false,
|
||||||
)
|
)
|
||||||
@@ -62,11 +66,8 @@ class MusicTransferService(
|
|||||||
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
private var pending: List<DownloadMusic> = emptyList()
|
private var pending: List<DownloadMusic> = emptyList()
|
||||||
private var systemBody: String = "android"
|
private val failedMusicIds = mutableSetOf<String>()
|
||||||
|
private val systemBody: String = if (PlatformInfo.isIos) "ios" else "android"
|
||||||
fun setPlatform(system: String) {
|
|
||||||
systemBody = system
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 再次打开快传页时清掉上次完成标记,避免一进页就自动返回。 */
|
/** 再次打开快传页时清掉上次完成标记,避免一进页就自动返回。 */
|
||||||
fun resetForOpen() {
|
fun resetForOpen() {
|
||||||
@@ -76,8 +77,10 @@ class MusicTransferService(
|
|||||||
|
|
||||||
fun connect(host: String) {
|
fun connect(host: String) {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
|
failedMusicIds.clear()
|
||||||
_state.value = TransferUiState(host = host, status = I18n.t("connecting"))
|
_state.value = TransferUiState(host = host, status = I18n.t("connecting"))
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
|
try {
|
||||||
if (!ws.connect(host, TransferChannel.MUSIC)) {
|
if (!ws.connect(host, TransferChannel.MUSIC)) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -89,14 +92,41 @@ class MusicTransferService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
|
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
|
||||||
ws.sendVersion()
|
ws.sendVersion()
|
||||||
try {
|
ws.receiveUntilClosed { cmd -> handle(cmd) }
|
||||||
ws.incoming().collect { cmd -> handle(cmd) }
|
if (_state.value.connected) {
|
||||||
} finally {
|
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 {
|
_state.update {
|
||||||
|
if (!it.connected || it.completed || it.error != null) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
it.copy(
|
it.copy(
|
||||||
connected = false,
|
connected = false,
|
||||||
verified = 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)
|
val result = Handshake.verify(cmd, Const.TRANS_VER)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
_state.update {
|
_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()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.update { it.copy(verified = true, status = I18n.t("verified")) }
|
_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))
|
ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody))
|
||||||
}
|
}
|
||||||
ProtocolCmd.PORT -> {
|
ProtocolCmd.PORT -> {
|
||||||
@@ -148,8 +184,15 @@ class MusicTransferService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending = need
|
pending = need
|
||||||
|
failedMusicIds.clear()
|
||||||
_state.update {
|
_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 })
|
val ids = json.encodeToString(need.map { it.musicUId })
|
||||||
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
|
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
|
||||||
@@ -158,8 +201,15 @@ class MusicTransferService(
|
|||||||
pending = runCatching {
|
pending = runCatching {
|
||||||
json.decodeFromString<List<DownloadMusic>>(cmd.body)
|
json.decodeFromString<List<DownloadMusic>>(cmd.body)
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
failedMusicIds.clear()
|
||||||
_state.update {
|
_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 -> {
|
ProtocolCmd.DOWNLOAD -> {
|
||||||
@@ -168,7 +218,13 @@ class MusicTransferService(
|
|||||||
val isLast = parts.getOrNull(1)?.trim().equals("true", ignoreCase = true)
|
val isLast = parts.getOrNull(1)?.trim().equals("true", ignoreCase = true)
|
||||||
val item = pending.find { it.musicUId == musicUId }
|
val item = pending.find { it.musicUId == musicUId }
|
||||||
if (item == null) {
|
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))
|
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
|
||||||
|
if (isLast) finishTransfer(musicUId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.update {
|
_state.update {
|
||||||
@@ -183,34 +239,17 @@ class MusicTransferService(
|
|||||||
val ok = downloadOne(item)
|
val ok = downloadOne(item)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
importOne(item)
|
importOne(item)
|
||||||
|
failedMusicIds.remove(musicUId)
|
||||||
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_SUCCESS.wire, musicUId))
|
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_SUCCESS.wire, musicUId))
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(progress = it.progress + 1)
|
it.copy(progress = it.progress + 1, failed = failedMusicIds.size)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
markFailed(musicUId)
|
||||||
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
|
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
|
||||||
}
|
}
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
|
finishTransfer(item.musicName)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
|
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
|
||||||
@@ -226,21 +265,76 @@ class MusicTransferService(
|
|||||||
val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/')
|
val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/')
|
||||||
val musicRel = AudioPathUtils.flac2wav(item.musicPath).orEmpty()
|
val musicRel = AudioPathUtils.flac2wav(item.musicPath).orEmpty()
|
||||||
val musicDest = root + item.baseUrl.trimStart('/') + musicRel.trimStart('/')
|
val musicDest = root + item.baseUrl.trimStart('/') + musicRel.trimStart('/')
|
||||||
|
val musicUrl = fileHttpUrl(item.baseUrl, musicRel)
|
||||||
return try {
|
return try {
|
||||||
if (item.coverPath.isNotBlank()) {
|
if (item.coverPath.isNotBlank()) {
|
||||||
runCatching {
|
val coverUrl = fileHttpUrl(item.baseUrl, item.coverPath)
|
||||||
fs.writeBytes(coverDest, http.downloadBytes(fileHttpUrl(item.baseUrl, item.coverPath)))
|
try {
|
||||||
if (fs.exists(coverDest)) {
|
val written = fs.writeBytes(coverDest, http.downloadBytes(coverUrl))
|
||||||
_state.update { it.copy(coverUrl = "file://$coverDest") }
|
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
|
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 {
|
private fun fileHttpUrl(vararg relativeParts: String): String {
|
||||||
val host = _state.value.host
|
val host = _state.value.host
|
||||||
val port = _state.value.httpPort.ifBlank { "10000" }
|
val port = _state.value.httpPort.ifBlank { "10000" }
|
||||||
@@ -290,4 +384,8 @@ class MusicTransferService(
|
|||||||
)
|
)
|
||||||
onImported()
|
onImported()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TRANSFER_TAG = "LLMP-Transfer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -47,6 +47,10 @@ actual object PlatformUi {
|
|||||||
exitProcess(0)
|
exitProcess(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun requestLocalNetworkPermission() {
|
||||||
|
WidgetDataWriter.post("llmpRequestLocalNetworkPermission")
|
||||||
|
}
|
||||||
|
|
||||||
actual fun setKeepScreenOn(enabled: Boolean) {
|
actual fun setKeepScreenOn(enabled: Boolean) {
|
||||||
dispatch_async(dispatch_get_main_queue()) {
|
dispatch_async(dispatch_get_main_queue()) {
|
||||||
UIApplication.sharedApplication.idleTimerDisabled = enabled
|
UIApplication.sharedApplication.idleTimerDisabled = enabled
|
||||||
|
|||||||
Reference in New Issue
Block a user