fix ios transfer song bug

This commit is contained in:
2026-10-03 15:20:50 +08:00
parent e3ce4aa9fd
commit 34842e2577
24 changed files with 415 additions and 102 deletions
@@ -53,7 +53,6 @@ class MainActivity : ComponentActivity() {
database = database, 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
@@ -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)
} }
} }
@@ -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,
+1 -1
View File
@@ -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;
+7
View File
@@ -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,
+39
View File
@@ -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")
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です"; NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です";
NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です"; NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です";
NSLocalNetworkUsageDescription = "パソコンに接続して楽曲の転送とデータ同期を行うために、ローカルネットワークへのアクセスが必要です";
@@ -1,2 +1,3 @@
NSCameraUsageDescription = "需要获取摄像头权限扫描二维码"; NSCameraUsageDescription = "需要获取摄像头权限扫描二维码";
NSPhotoLibraryUsageDescription = "需要读取相册获取图片"; NSPhotoLibraryUsageDescription = "需要读取相册获取图片";
NSLocalNetworkUsageDescription = "需要访问本地网络以连接电脑传输歌曲和同步数据";
+6 -2
View File
@@ -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:
@@ -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"
}
} }
@@ -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 本身也可能抛网络异常,清理操作不应继续向上冒泡。
}
} }
} }
@@ -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)
} }
@@ -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"
}
} }
@@ -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"
}
} }
@@ -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