From fbf9674ebbfd1f174f0bf62a517cb11a04d1f8fc Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Fri, 27 Mar 2026 00:47:09 +0800 Subject: [PATCH 01/11] feat: implement listen together room sync and controls --- app/src/main/AndroidManifest.xml | 9 + .../ouom/neriplayer/activity/MainActivity.kt | 305 ++- .../core/api/bili/BiliSongResolver.kt | 5 +- .../ouom/neriplayer/core/di/AppContainer.kt | 13 + .../neriplayer/core/player/PlayerManager.kt | 387 +++- .../data/ListenTogetherPreferences.kt | 152 ++ .../data/LocalAudioImportManager.kt | 8 +- .../ouom/neriplayer/data/LocalMediaSupport.kt | 4 +- .../data/github/GitHubSyncManager.kt | 6 +- .../neriplayer/data/github/SyncDataModels.kt | 18 +- .../data/github/SyncDataSerializer.kt | 6 +- .../listentogether/ListenTogetherApi.kt | 149 ++ .../listentogether/ListenTogetherInvite.kt | 95 + .../listentogether/ListenTogetherMapper.kt | 227 +++ .../listentogether/ListenTogetherProtocol.kt | 231 +++ .../ListenTogetherSessionManager.kt | 1736 +++++++++++++++++ .../ListenTogetherValidation.kt | 113 ++ .../ListenTogetherWebSocketClient.kt | 89 + .../neriplayer/ui/screen/NowPlayingScreen.kt | 42 +- .../ui/screen/debug/DebugHomeScreen.kt | 60 +- .../screen/debug/ListenTogetherDebugPanel.kt | 493 +++++ .../ui/screen/tab/SettingsScreen.kt | 312 +++ .../NeteasePlaylistDetailViewModel.kt | 11 +- .../YouTubeMusicPlaylistDetailViewModel.kt | 5 +- .../ui/viewmodel/tab/ExploreViewModel.kt | 12 +- app/src/main/res/values-en/strings.xml | 79 + app/src/main/res/values/strings.xml | 79 + 27 files changed, 4578 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/moe/ouom/neriplayer/data/ListenTogetherPreferences.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherMapper.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherProtocol.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherValidation.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherWebSocketClient.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fd7cbc64..c040bc0e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,15 @@ + + + + + + (null) + private val listenTogetherInviteFlow = pendingListenTogetherInvite.asStateFlow() + private var lastObservedClipboardInviteSignature: String? = null + private var clipboardInviteInspectJob: Job? = null + private var hasWindowFocusForClipboardInspection = false + private val listenTogetherStatusMessage = MutableStateFlow(null) + private val listenTogetherStatusFlow = listenTogetherStatusMessage.asStateFlow() override fun attachBaseContext(newBase: Context) { super.attachBaseContext(LanguageManager.applyLanguage(newBase)) @@ -192,7 +211,8 @@ class MainActivity : ComponentActivity() { NeriTheme(useDark = useDark, useDynamic = dynamicColor) { LaunchedEffect(Unit) { - handleExternalAudioIntent(intent) + handleIncomingIntent(intent) + inspectClipboardForListenTogetherInvite() } SideEffect { val controller = WindowInsetsControllerCompat(window, window.decorView) @@ -252,6 +272,29 @@ class MainActivity : ComponentActivity() { var errorTitle by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") } val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + var joiningInvite by remember { mutableStateOf(false) } + val pendingInvite by listenTogetherInviteFlow.collectAsState() + val listenTogetherStatus by listenTogetherStatusFlow.collectAsState() + val listenTogetherSessionState by AppContainer.listenTogetherSessionManager.sessionState.collectAsState() + val listenTogetherRoomState by AppContainer.listenTogetherSessionManager.roomState.collectAsState() + val isListenTogetherRoomActive = !listenTogetherSessionState.roomId.isNullOrBlank() + var hadActiveListenTogetherRoom by rememberSaveable { mutableStateOf(false) } + var lastShownListenTogetherMemberNotice by rememberSaveable { mutableStateOf(null) } + val effectiveListenTogetherStatus = when { + joiningInvite -> getString(R.string.listen_together_status_joining) + !listenTogetherStatus.isNullOrBlank() -> listenTogetherStatus + isListenTogetherRoomActive && + listenTogetherSessionState.connectionState == moe.ouom.neriplayer.listentogether.ListenTogetherConnectionState.CONNECTING -> + getString(R.string.listen_together_status_syncing) + isListenTogetherRoomActive -> getString(R.string.listen_together_status_active) + else -> null + } + val showLeaveListenTogetherAction = isListenTogetherRoomActive && ( + dialogMessage == getString(R.string.listen_together_error_controller_offline) || + dialogMessage.contains("一起听", ignoreCase = false) || + dialogMessage.contains("controller offline", ignoreCase = true) + ) // 初始化异常处理器事件监听 LaunchedEffect(Unit) { @@ -310,6 +353,53 @@ class MainActivity : ComponentActivity() { } } + LaunchedEffect( + listenTogetherSessionState.roomId, + listenTogetherSessionState.connectionState + ) { + updateListenTogetherStatus( + when { + listenTogetherSessionState.roomId.isNullOrBlank() -> null + listenTogetherSessionState.connectionState == moe.ouom.neriplayer.listentogether.ListenTogetherConnectionState.CONNECTING -> + getString(R.string.listen_together_status_syncing) + else -> getString(R.string.listen_together_status_active) + } + ) + } + + LaunchedEffect(isListenTogetherRoomActive) { + when { + isListenTogetherRoomActive -> hadActiveListenTogetherRoom = true + hadActiveListenTogetherRoom -> { + clearListenTogetherInviteCache() + hadActiveListenTogetherRoom = false + } + } + } + + LaunchedEffect(effectiveListenTogetherStatus) { + effectiveListenTogetherStatus?.let(::showListenTogetherStatusToast) + } + + LaunchedEffect( + listenTogetherSessionState.roomNotice, + listenTogetherRoomState?.version + ) { + val notice = listenTogetherSessionState.roomNotice ?: return@LaunchedEffect + if (!notice.startsWith("member_joined:") && !notice.startsWith("member_left:")) { + return@LaunchedEffect + } + val noticeKey = "${listenTogetherRoomState?.version ?: -1L}:$notice" + if (lastShownListenTogetherMemberNotice == noticeKey) { + return@LaunchedEffect + } + lastShownListenTogetherMemberNotice = noticeKey + showListenTogetherStatusToast( + message = notice.toListenTogetherNoticeDisplay(), + atBottom = true + ) + } + if (showDialog) { AlertDialog( onDismissRequest = { showDialog = false }, @@ -319,11 +409,110 @@ class MainActivity : ComponentActivity() { HapticTextButton(onClick = { showDialog = false }) { Text(stringResource(R.string.action_confirm)) } + }, + dismissButton = if (showLeaveListenTogetherAction) { + { + HapticTextButton( + onClick = { + AppContainer.listenTogetherSessionManager.leaveRoom() + showDialog = false + } + ) { + Text(stringResource(R.string.listen_together_leave_room)) + } + } + } else { + null } ) } // 异常错误弹窗 + pendingInvite?.let { invite -> + val inviterNickname = invite.inviterNickname + AlertDialog( + onDismissRequest = { + if (!joiningInvite) { + clearPendingListenTogetherInvite() + } + }, + title = { Text(stringResource(R.string.listen_together_join_invite_title)) }, + text = { + Text( + if (!inviterNickname.isNullOrBlank()) { + stringResource( + R.string.listen_together_join_invite_message_with_inviter, + inviterNickname, + invite.roomId + ) + } else { + stringResource( + R.string.listen_together_join_invite_message, + invite.roomId + ) + } + ) + }, + confirmButton = { + HapticTextButton( + onClick = { + scope.launch { + joiningInvite = true + try { + val preferences = AppContainer.listenTogetherPreferences + val sessionManager = AppContainer.listenTogetherSessionManager + updateListenTogetherStatus(getString(R.string.listen_together_status_joining)) + val baseUrl = resolveListenTogetherBaseUrl( + preferences.workerBaseUrlFlow.first() + .ifBlank { + invite.baseUrl ?: DEFAULT_LISTEN_TOGETHER_BASE_URL + } + ) + val userUuid = preferences.getOrCreateUserUuid() + val nickname = preferences.getOrCreateNickname() + preferences.setWorkerBaseUrl(baseUrl) + PlayerManager.resetForListenTogetherJoin() + sessionManager.leaveRoom() + updateListenTogetherStatus(getString(R.string.listen_together_status_syncing)) + sessionManager.joinRoom( + baseUrl = baseUrl, + roomId = invite.roomId, + userUuid = userUuid, + nickname = nickname + ) + sessionManager.connectWebSocket() + clearPendingListenTogetherInvite() + } catch (error: Throwable) { + updateListenTogetherStatus(null) + dialogMessage = error.message ?: error.javaClass.simpleName + showDialog = true + } finally { + joiningInvite = false + } + } + }, + enabled = !joiningInvite + ) { + Text( + if (joiningInvite) { + stringResource(R.string.listen_together_joining_room) + } else { + stringResource(R.string.listen_together_join_room) + } + ) + } + }, + dismissButton = { + HapticTextButton( + onClick = { clearPendingListenTogetherInvite() }, + enabled = !joiningInvite + ) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + if (showErrorDialog) { AlertDialog( onDismissRequest = { showErrorDialog = false }, @@ -399,9 +588,122 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + handleIncomingIntent(intent) + scheduleClipboardInviteInspection(immediate = true) + } + + override fun onResume() { + super.onResume() + if (hasWindowFocusForClipboardInspection) { + scheduleClipboardInviteInspection() + } + } + + override fun onPause() { + clipboardInviteInspectJob?.cancel() + super.onPause() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + hasWindowFocusForClipboardInspection = hasFocus + if (hasFocus) { + scheduleClipboardInviteInspection() + } + } + + private fun handleIncomingIntent(intent: Intent?) { + if (handleListenTogetherInviteIntent(intent)) return handleExternalAudioIntent(intent) } + private fun handleListenTogetherInviteIntent(intent: Intent?): Boolean { + val invite = parseListenTogetherInvite(intent?.data) ?: return false + presentListenTogetherInvite(invite) + setIntent(Intent(this, MainActivity::class.java)) + return true + } + + private fun inspectClipboardForListenTogetherInvite() { + val clipboard = getSystemService(ClipboardManager::class.java) ?: return + val clipText = clipboard.primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(this) + ?.toString() + val invite = parseListenTogetherInvite(clipText) + if (invite == null) { + lastObservedClipboardInviteSignature = null + return + } + if (lastObservedClipboardInviteSignature == invite.signature) return + lastObservedClipboardInviteSignature = invite.signature + presentListenTogetherInvite(invite) + } + + private fun scheduleClipboardInviteInspection(immediate: Boolean = false) { + clipboardInviteInspectJob?.cancel() + clipboardInviteInspectJob = lifecycleScope.launch { + if (!immediate) { + delay(180) + } + inspectClipboardForListenTogetherInvite() + } + } + + private fun clearPendingListenTogetherInvite() { + pendingListenTogetherInvite.value = null + } + + private fun clearListenTogetherInviteCache() { + lastObservedClipboardInviteSignature = null + clearPendingListenTogetherInvite() + } + + private fun updateListenTogetherStatus(message: String?) { + listenTogetherStatusMessage.value = message + } + + private fun showListenTogetherStatusToast( + message: String, + atBottom: Boolean = false + ) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).apply { + if (atBottom) { + setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, 0, 220) + } else { + setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 180) + } + }.show() + } + + private fun String.toListenTogetherNoticeDisplay(): String = when { + startsWith("controller_offline:") -> + getString( + R.string.listen_together_notice_controller_offline, + substringAfter(':').toLongOrNull() ?: 10L + ) + startsWith("member_joined:") -> + getString(R.string.listen_together_notice_member_joined, substringAfter(':')) + startsWith("member_left:") -> + getString(R.string.listen_together_notice_member_left, substringAfter(':')) + this == "controller_reconnected" -> + getString(R.string.listen_together_notice_controller_reconnected) + this == "controller_timeout" || this == "room_closed" -> + getString(R.string.listen_together_notice_room_closed) + else -> this + } + + private fun presentListenTogetherInvite(invite: ListenTogetherInvite) { + val currentRoomId = AppContainer.listenTogetherSessionManager.sessionState.value.roomId + ?.let(::normalizeListenTogetherRoomId) + if (currentRoomId != null && currentRoomId == invite.roomId) { + return + } + if (pendingListenTogetherInvite.value?.signature == invite.signature) return + pendingListenTogetherInvite.value = invite + } + private fun handleExternalAudioIntent(intent: Intent?) { val action = intent?.action ?: return val uriList: List = when (action) { @@ -439,6 +741,7 @@ class MainActivity : ComponentActivity() { } override fun onDestroy() { + clipboardInviteInspectJob?.cancel() externalAudioImportJob?.cancel() super.onDestroy() ExceptionHandler.cleanup() diff --git a/app/src/main/java/moe/ouom/neriplayer/core/api/bili/BiliSongResolver.kt b/app/src/main/java/moe/ouom/neriplayer/core/api/bili/BiliSongResolver.kt index 00b15067..962779be 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/api/bili/BiliSongResolver.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/api/bili/BiliSongResolver.kt @@ -26,7 +26,10 @@ fun buildBiliPartSong( album = "${PlayerManager.BILI_SOURCE_TAG}|${page.cid}", albumId = 0L, durationMs = page.durationSec * 1000L, - coverUrl = coverUrl + coverUrl = coverUrl, + channelId = "bilibili", + audioId = basicInfo.aid.toString(), + subAudioId = page.cid.toString() ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt b/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt index f7192e76..036e5e66 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt @@ -42,6 +42,7 @@ import moe.ouom.neriplayer.core.api.search.QQMusicSearchApi import moe.ouom.neriplayer.core.api.youtube.YouTubeMusicClient import moe.ouom.neriplayer.core.api.youtube.YouTubeMusicPlaybackRepository import moe.ouom.neriplayer.data.BiliCookieRepository +import moe.ouom.neriplayer.data.ListenTogetherPreferences import moe.ouom.neriplayer.data.NeteaseCookieRepository import moe.ouom.neriplayer.data.PlayHistoryRepository import moe.ouom.neriplayer.data.PlaylistUsageRepository @@ -51,6 +52,9 @@ import moe.ouom.neriplayer.data.YOUTUBE_MUSIC_ORIGIN import moe.ouom.neriplayer.data.buildYouTubeInnertubeRequestHeaders import moe.ouom.neriplayer.data.buildYouTubePageRequestHeaders import moe.ouom.neriplayer.data.buildYouTubeStreamRequestHeaders +import moe.ouom.neriplayer.listentogether.ListenTogetherApi +import moe.ouom.neriplayer.listentogether.ListenTogetherSessionManager +import moe.ouom.neriplayer.listentogether.ListenTogetherWebSocketClient import moe.ouom.neriplayer.util.DynamicProxySelector import okhttp3.OkHttpClient import okhttp3.Request @@ -66,6 +70,7 @@ object AppContainer { // 基础 Repo val settingsRepo by lazy { SettingsRepository(application) } + val listenTogetherPreferences by lazy { ListenTogetherPreferences(application) } val neteaseCookieRepo by lazy { NeteaseCookieRepository(application) } val biliCookieRepo by lazy { BiliCookieRepository(application) } val youtubeAuthRepo by lazy { YouTubeAuthRepository(application) } @@ -149,6 +154,14 @@ object AppContainer { val cloudMusicSearchApi by lazy { CloudMusicSearchApi(neteaseClient) } val qqMusicSearchApi by lazy { QQMusicSearchApi() } val lrcLibClient by lazy { moe.ouom.neriplayer.core.api.lyrics.LrcLibClient(sharedOkHttpClient) } + val listenTogetherApi by lazy { ListenTogetherApi(sharedOkHttpClient) } + val listenTogetherWebSocketClient by lazy { ListenTogetherWebSocketClient(sharedOkHttpClient) } + val listenTogetherSessionManager by lazy { + ListenTogetherSessionManager( + api = listenTogetherApi, + webSocketClient = listenTogetherWebSocketClient + ) + } fun launchBackgroundIo(block: suspend CoroutineScope.() -> Unit) = scope.launch(block = block) diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt index f42a5775..7ec6110c 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt @@ -101,6 +101,12 @@ import moe.ouom.neriplayer.ui.component.LyricEntry import moe.ouom.neriplayer.ui.component.parseNeteaseLrc import moe.ouom.neriplayer.ui.viewmodel.playlist.BiliVideoItem import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +import moe.ouom.neriplayer.listentogether.ListenTogetherChannels +import moe.ouom.neriplayer.listentogether.buildStableTrackKey +import moe.ouom.neriplayer.listentogether.resolvedAudioId +import moe.ouom.neriplayer.listentogether.resolvedChannelId +import moe.ouom.neriplayer.listentogether.resolvedPlaylistContextId +import moe.ouom.neriplayer.listentogether.resolvedSubAudioId import moe.ouom.neriplayer.util.NPLogger import moe.ouom.neriplayer.util.SearchManager import org.json.JSONArray @@ -127,10 +133,26 @@ private sealed class SongUrlResult { val durationMs: Long? = null, val mimeType: String? = null ) : SongUrlResult() + object WaitingForAuthoritativeStream : SongUrlResult() object RequiresLogin : SongUrlResult() object Failure : SongUrlResult() } +enum class PlaybackCommandSource { + LOCAL, + REMOTE_SYNC +} + +data class PlaybackCommand( + val type: String, + val source: PlaybackCommandSource, + val timestampMs: Long = System.currentTimeMillis(), + val queue: List? = null, + val currentIndex: Int? = null, + val positionMs: Long? = null, + val force: Boolean = false +) + /** * PlayerManager 负责: * - 初始化 ExoPlayer、缓存、渲染管线,并与应用配置(音质、Cookie 等)打通 @@ -217,6 +239,8 @@ object PlayerManager { private var resumePlaybackRequested = false @Volatile private var suppressAutoResumeForCurrentSession = false + @Volatile + private var listenTogetherSyncPlaybackRate = 1f private val _currentSongFlow = MutableStateFlow(null) val currentSongFlow: StateFlow = _currentSongFlow @@ -243,6 +267,11 @@ object PlayerManager { private val _playerEventFlow = MutableSharedFlow() val playerEventFlow: SharedFlow = _playerEventFlow.asSharedFlow() + private val _playbackCommandFlow = MutableSharedFlow( + extraBufferCapacity = 32 + ) + val playbackCommandFlow: SharedFlow = _playbackCommandFlow.asSharedFlow() + /** 向 UI 暴露当前实际播放链接,用于来源展示 */ private val _currentMediaUrl = MutableStateFlow(null) val currentMediaUrlFlow: StateFlow = _currentMediaUrl @@ -312,6 +341,52 @@ object PlayerManager { player.playbackState == Player.STATE_BUFFERING ) + fun setListenTogetherSyncPlaybackRate(rate: Float) { + ensureInitialized() + if (!initialized || !::player.isInitialized) return + val resolvedRate = rate.coerceIn(0.95f, 1.05f) + if (kotlin.math.abs(listenTogetherSyncPlaybackRate - resolvedRate) < 0.001f) return + listenTogetherSyncPlaybackRate = resolvedRate + mainScope.launch { + if (::player.isInitialized) { + player.setPlaybackSpeed(resolvedRate) + } + } + } + + fun resetListenTogetherSyncPlaybackRate() { + setListenTogetherSyncPlaybackRate(1f) + } + + fun resetForListenTogetherJoin() { + ensureInitialized() + if (!initialized) return + cancelPendingPauseRequest(resetVolumeToFull = true) + playbackRequestToken += 1 + playJob?.cancel() + playJob = null + resumePlaybackRequested = false + restoredShouldResumePlayback = false + restoredResumePositionMs = 0L + stopProgressUpdates() + cancelVolumeFade(resetToFull = true) + runCatching { player.stop() } + runCatching { player.clearMediaItems() } + _isPlayingFlow.value = false + clearPendingSeekPosition() + _playbackPositionMs.value = 0L + _currentMediaUrl.value = null + currentMediaUrlResolvedAtMs = 0L + _currentSongFlow.value = null + _currentQueueFlow.value = emptyList() + currentPlaylist = emptyList() + currentIndex = -1 + consecutivePlayFailures = 0 + ioScope.launch { + persistState(positionMs = 0L, shouldResumePlayback = false) + } + } + private fun pendingSeekPositionOrNull(): Long? { return pendingSeekPositionMs.takeIf { it != C.TIME_UNSET } } @@ -339,6 +414,108 @@ object PlayerManager { private fun isLocalSong(song: SongItem): Boolean = LocalSongSupport.isLocalSong(song, application) + private fun isDirectStreamUrl(url: String?): Boolean { + val normalized = url?.trim().orEmpty() + return normalized.startsWith("https://", ignoreCase = true) || + normalized.startsWith("http://", ignoreCase = true) + } + + private fun activeListenTogetherRoomState() = AppContainer.listenTogetherSessionManager.roomState.value + + private fun activeListenTogetherSessionState() = AppContainer.listenTogetherSessionManager.sessionState.value + + private fun isListenTogetherActive(): Boolean { + return !activeListenTogetherSessionState().roomId.isNullOrBlank() + } + + private fun isCurrentUserControllerInListenTogether(): Boolean { + val session = activeListenTogetherSessionState() + val room = activeListenTogetherRoomState() + val sessionUserId = session.userUuid?.trim()?.takeIf { it.isNotBlank() } + val controllerUserId = room?.controllerUserUuid?.trim()?.takeIf { it.isNotBlank() } + ?: room?.controllerUserId?.trim()?.takeIf { it.isNotBlank() } + return sessionUserId != null && controllerUserId != null && sessionUserId == controllerUserId + } + + private fun currentListenTogetherTargetStableKey(): String? { + val room = activeListenTogetherRoomState() ?: return null + return room.track?.stableKey ?: room.queue.getOrNull(room.currentIndex)?.stableKey + } + + private fun currentListenTogetherTargetStreamUrl(): String? { + val room = activeListenTogetherRoomState() ?: return null + return room.track?.streamUrl ?: room.queue.getOrNull(room.currentIndex)?.streamUrl + } + + private fun SongItem.listenTogetherStableKeyOrNull(): String? { + val channel = resolvedChannelId() ?: return null + val audioId = resolvedAudioId() ?: return null + return buildStableTrackKey( + channelId = channel, + audioId = audioId, + subAudioId = resolvedSubAudioId(), + playlistContextId = resolvedPlaylistContextId() + ) + } + + private fun shouldWaitForListenTogetherAuthoritativeStream(song: SongItem): Boolean { + if (!isListenTogetherActive()) return false + if (isCurrentUserControllerInListenTogether()) return false + val room = activeListenTogetherRoomState() ?: return false + if (!room.settings.shareAudioLinks || room.roomStatus != "active") return false + if (isDirectStreamUrl(currentListenTogetherTargetStreamUrl())) return false + val targetStableKey = currentListenTogetherTargetStableKey() ?: return false + val songStableKey = song.listenTogetherStableKeyOrNull() ?: return false + return songStableKey == targetStableKey + } + + private fun stopCurrentPlaybackForListenTogetherAwaitingStream() { + cancelPendingPauseRequest(resetVolumeToFull = true) + stopProgressUpdates() + cancelVolumeFade(resetToFull = true) + runCatching { player.stop() } + runCatching { player.clearMediaItems() } + _isPlayingFlow.value = false + _currentMediaUrl.value = null + currentMediaUrlResolvedAtMs = 0L + clearPendingSeekPosition() + _playbackPositionMs.value = 0L + } + + private fun rejectListenTogetherControl(messageResId: Int): Boolean { + postPlayerEvent(PlayerEvent.ShowError(getLocalizedString(messageResId))) + return true + } + + private fun shouldBlockLocalRoomControl(commandSource: PlaybackCommandSource): Boolean { + if (commandSource != PlaybackCommandSource.LOCAL) return false + if (!isListenTogetherActive()) return false + val room = activeListenTogetherRoomState() + if (room?.roomStatus == "controller_offline" && !isCurrentUserControllerInListenTogether()) { + return rejectListenTogetherControl(R.string.listen_together_error_controller_offline) + } + if (room?.settings?.allowMemberControl == false && !isCurrentUserControllerInListenTogether()) { + return rejectListenTogetherControl(R.string.listen_together_error_member_control_disabled) + } + return false + } + + private fun shouldBlockLocalSongSwitch(song: SongItem, commandSource: PlaybackCommandSource): Boolean { + if (commandSource != PlaybackCommandSource.LOCAL) return false + if (!isListenTogetherActive()) return false + if (!isLocalSong(song)) return false + return rejectListenTogetherControl(R.string.listen_together_error_local_playback_blocked) + } + + private fun isYouTubeMusicTrack(song: SongItem): Boolean { + return song.channelId == ListenTogetherChannels.YOUTUBE_MUSIC || isYouTubeMusicSong(song) + } + + private fun isBiliTrack(song: SongItem): Boolean { + return song.channelId == ListenTogetherChannels.BILIBILI || + song.album.startsWith(BILI_SOURCE_TAG) + } + private fun queueIndexOf(song: SongItem, playlist: List = currentPlaylist): Int { return playlist.indexOfFirst { it.sameIdentityAs(song) } } @@ -433,6 +610,27 @@ object PlayerManager { ioScope.launch { _playerEventFlow.emit(event) } } + private fun emitPlaybackCommand( + type: String, + source: PlaybackCommandSource, + queue: List? = null, + currentIndex: Int? = null, + positionMs: Long? = null, + force: Boolean = false + ) { + if (source != PlaybackCommandSource.LOCAL) return + _playbackCommandFlow.tryEmit( + PlaybackCommand( + type = type, + source = source, + queue = queue, + currentIndex = currentIndex, + positionMs = positionMs, + force = force + ) + ) + } + /** * 仅允许 ExoPlayer 在“单曲循环”时循环;其余一律 OFF,由队列逻辑接管 */ @@ -461,17 +659,17 @@ object PlayerManager { private fun computeCacheKey(song: SongItem): String { return when { isLocalSong(song) -> "local-${song.stableKey().hashCode()}" - isYouTubeMusicSong(song) -> { - val videoId = extractYouTubeMusicVideoId(song.mediaUri).orEmpty() + isYouTubeMusicTrack(song) -> { + val videoId = song.audioId ?: extractYouTubeMusicVideoId(song.mediaUri).orEmpty() "ytmusic-$videoId-$youtubePreferredQuality-m4a" } - song.album.startsWith(BILI_SOURCE_TAG) -> { - val parts = song.album.split('|') - val cidPart = if (parts.size > 1) parts[1] else null + isBiliTrack(song) -> { + val cidPart = song.subAudioId ?: song.album.split('|').getOrNull(1) + val biliSongId = song.audioId ?: song.id.toString() if (cidPart != null) { - "bili-${song.id}-$cidPart-$biliPreferredQuality" + "bili-$biliSongId-$cidPart-$biliPreferredQuality" } else { - "bili-${song.id}-$biliPreferredQuality" + "bili-$biliSongId-$biliPreferredQuality" } } else -> "netease-${song.id}-$preferredQuality" @@ -1184,13 +1382,21 @@ object PlayerManager { } } - fun playPlaylist(songs: List, startIndex: Int) { + fun playPlaylist( + songs: List, + startIndex: Int, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL + ) { ensureInitialized() check(initialized) { "Call PlayerManager.initialize(application) first." } if (songs.isEmpty()) { NPLogger.w("NERI-Player", "playPlaylist called with EMPTY list") return } + val targetSong = songs.getOrNull(startIndex.coerceIn(0, songs.lastIndex)) ?: songs.first() + if (shouldBlockLocalRoomControl(commandSource) || shouldBlockLocalSongSwitch(targetSong, commandSource)) { + return + } suppressAutoResumeForCurrentSession = false consecutivePlayFailures = 0 currentPlaylist = songs @@ -1207,7 +1413,13 @@ object PlayerManager { } maybeWarmCurrentAndUpcomingYouTubeMusic(currentIndex) - playAtIndex(currentIndex) + playAtIndex(currentIndex, commandSource = commandSource) + emitPlaybackCommand( + type = "PLAY_PLAYLIST", + source = commandSource, + queue = currentPlaylist, + currentIndex = currentIndex + ) ioScope.launch { persistState() } @@ -1223,7 +1435,8 @@ object PlayerManager { private fun playAtIndex( index: Int, resumePositionMs: Long = 0L, - useTrackTransitionFade: Boolean = false + useTrackTransitionFade: Boolean = false, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL ) { if (currentPlaylist.isEmpty() || index !in currentPlaylist.indices) { NPLogger.w("NERI-Player", "playAtIndex called with invalid index: $index") @@ -1248,6 +1461,12 @@ object PlayerManager { _currentSongFlow.value = song _currentMediaUrl.value = null currentMediaUrlResolvedAtMs = 0L + val shouldAwaitAuthoritativeStream = + commandSource == PlaybackCommandSource.REMOTE_SYNC && + shouldWaitForListenTogetherAuthoritativeStream(song) + if (shouldAwaitAuthoritativeStream) { + stopCurrentPlaybackForListenTogetherAwaitingStream() + } resumePlaybackRequested = true restoredShouldResumePlayback = false restoredResumePositionMs = 0L @@ -1338,6 +1557,19 @@ object PlayerManager { maybeAutoMatchBiliMetadata(song, requestToken) maybeWarmCurrentAndUpcomingYouTubeMusic(index) } + SongUrlResult.WaitingForAuthoritativeStream -> { + NPLogger.d( + "NERI-PlayerManager", + "Waiting for authoritative listen-together stream: song=${song.name}, stableKey=${song.listenTogetherStableKeyOrNull()}" + ) + resumePlaybackRequested = false + ioScope.launch { + persistState( + positionMs = resumePositionMs.coerceAtLeast(0L), + shouldResumePlayback = false + ) + } + } is SongUrlResult.RequiresLogin -> { NPLogger.w("NERI-PlayerManager", "Requires login to play: id=${song.id}, source=${song.album}") postPlayerEvent( @@ -1357,7 +1589,7 @@ object PlayerManager { } private fun maybeAutoMatchBiliMetadata(song: SongItem, requestToken: Long) { - if (!song.album.startsWith(BILI_SOURCE_TAG)) return + if (!isBiliTrack(song)) return if (song.matchedSongId != null || !song.matchedLyric.isNullOrEmpty()) return if (song.customName != null || song.customArtist != null || song.customCoverUrl != null) return @@ -1435,6 +1667,12 @@ object PlayerManager { song: SongItem, forceRefresh: Boolean = false ): SongUrlResult { + if (shouldWaitForListenTogetherAuthoritativeStream(song)) { + return SongUrlResult.WaitingForAuthoritativeStream + } + if (isDirectStreamUrl(song.streamUrl)) { + return SongUrlResult.Success(song.streamUrl.orEmpty()) + } if (isLocalSong(song)) { val localMediaUri = localMediaSource(song) if (localMediaUri != null && isReadableLocalMediaUri(localMediaUri)) { @@ -1451,17 +1689,17 @@ object PlayerManager { val cacheKey = computeCacheKey(song) val hasCachedData = checkExoPlayerCache(cacheKey) val result = when { - isYouTubeMusicSong(song) -> getYouTubeMusicAudioUrl( + isYouTubeMusicTrack(song) -> getYouTubeMusicAudioUrl( song = song, suppressError = hasCachedData, forceRefresh = forceRefresh ) - song.album.startsWith(BILI_SOURCE_TAG) -> getBiliAudioUrl(song, suppressError = hasCachedData) + isBiliTrack(song) -> getBiliAudioUrl(song, suppressError = hasCachedData) else -> getNeteaseSongUrl(song.id, suppressError = hasCachedData) } // 如果网络失败但有缓存,使用虚拟URL让ExoPlayer使用缓存 - return if (result is SongUrlResult.Failure && hasCachedData && !isYouTubeMusicSong(song)) { + return if (result is SongUrlResult.Failure && hasCachedData && !isYouTubeMusicTrack(song)) { NPLogger.d("NERI-PlayerManager", "网络失败但有缓存,尝试离线播放: $cacheKey") // 使用虚拟URL,ExoPlayer会因为customCacheKey自动使用缓存 SongUrlResult.Success("http://offline.cache/$cacheKey") @@ -1551,7 +1789,7 @@ object PlayerManager { NPLogger.d("NERI-PlayerManager", "Refreshing stream url ($reason): $cacheKey") val result = resolveSongUrl( song = song, - forceRefresh = isYouTubeMusicSong(song) + forceRefresh = isYouTubeMusicTrack(song) ) if (result is SongUrlResult.Success && _currentSongFlow.value?.sameIdentityAs(song) == true @@ -1848,9 +2086,10 @@ object PlayerManager { playPlaylist(songs, startIndex) } - fun play() { + fun play(commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL) { ensureInitialized() if (!initialized) return + if (shouldBlockLocalRoomControl(commandSource)) return cancelPendingPauseRequest(resetVolumeToFull = true) suppressAutoResumeForCurrentSession = false resumePlaybackRequested = true @@ -1892,6 +2131,12 @@ object PlayerManager { shouldResumePlayback = true ) } + emitPlaybackCommand( + type = "PLAY", + source = commandSource, + positionMs = resumePositionMs, + currentIndex = currentIndex + ) } currentPlaylist.isNotEmpty() && currentIndex != -1 -> { val resumePositionMs = if (keepLastPlaybackProgressEnabled) { @@ -1900,8 +2145,22 @@ object PlayerManager { 0L } playAtIndex(currentIndex, resumePositionMs = resumePositionMs) + emitPlaybackCommand( + type = "PLAY", + source = commandSource, + positionMs = resumePositionMs, + currentIndex = currentIndex + ) + } + currentPlaylist.isNotEmpty() -> { + playAtIndex(0) + emitPlaybackCommand( + type = "PLAY", + source = commandSource, + positionMs = 0L, + currentIndex = 0 + ) } - currentPlaylist.isNotEmpty() -> playAtIndex(0) else -> {} } } @@ -1936,9 +2195,13 @@ object PlayerManager { handleTrackEnded() } - fun pause(forcePersist: Boolean = false) { + fun pause( + forcePersist: Boolean = false, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL + ) { ensureInitialized() if (!initialized) return + if (shouldBlockLocalRoomControl(commandSource)) return cancelPendingPauseRequest() resumePlaybackRequested = false playbackRequestToken += 1 @@ -1973,6 +2236,12 @@ object PlayerManager { } else { pauseInternal(forcePersist, resetVolumeToFull = true) } + emitPlaybackCommand( + type = "PAUSE", + source = commandSource, + positionMs = _playbackPositionMs.value, + currentIndex = currentIndex + ) } private fun pauseInternal(forcePersist: Boolean, resetVolumeToFull: Boolean) { @@ -2031,9 +2300,13 @@ object PlayerManager { } } - fun seekTo(positionMs: Long) { + fun seekTo( + positionMs: Long, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL + ) { ensureInitialized() if (!initialized) return + if (shouldBlockLocalRoomControl(commandSource)) return val resolvedPositionMs = positionMs.coerceAtLeast(0L) if (YouTubeSeekRefreshPolicy.shouldRefreshUrlBeforeSeek(_currentSongFlow.value, _currentMediaUrl.value)) { rememberPendingSeekPosition(resolvedPositionMs) @@ -2048,11 +2321,21 @@ object PlayerManager { shouldResumePlayback = shouldResumePlaybackSnapshot() ) } + emitPlaybackCommand( + type = "SEEK", + source = commandSource, + positionMs = resolvedPositionMs, + currentIndex = currentIndex + ) } - fun next(force: Boolean = false) { + fun next( + force: Boolean = false, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL + ) { ensureInitialized() if (!initialized) return + if (shouldBlockLocalRoomControl(commandSource)) return if (currentPlaylist.isEmpty()) return val isShuffle = player.shuffleModeEnabled val useTransitionFade = @@ -2065,6 +2348,12 @@ object PlayerManager { if (currentIndex != -1) shuffleHistory.add(currentIndex) currentIndex = nextIdx playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "NEXT", + source = commandSource, + currentIndex = currentIndex, + force = force + ) return } @@ -2092,6 +2381,12 @@ object PlayerManager { val pick = if (shuffleBag.size == 1) 0 else Random.nextInt(shuffleBag.size) currentIndex = shuffleBag.removeAt(pick) playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "NEXT", + source = commandSource, + currentIndex = currentIndex, + force = force + ) } else { // 顺序播放 if (currentIndex < currentPlaylist.lastIndex) { @@ -2105,12 +2400,19 @@ object PlayerManager { } } playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "NEXT", + source = commandSource, + currentIndex = currentIndex, + force = force + ) } } - fun previous() { + fun previous(commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL) { ensureInitialized() if (!initialized) return + if (shouldBlockLocalRoomControl(commandSource)) return if (currentPlaylist.isEmpty()) return val isShuffle = player.shuffleModeEnabled val useTransitionFade = @@ -2123,6 +2425,11 @@ object PlayerManager { val prev = shuffleHistory.removeAt(shuffleHistory.lastIndex) currentIndex = prev playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "PREVIOUS", + source = commandSource, + currentIndex = currentIndex + ) } else { NPLogger.d("NERI-Player", "No previous track in shuffle history.") } @@ -2130,10 +2437,20 @@ object PlayerManager { if (currentIndex > 0) { currentIndex-- playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "PREVIOUS", + source = commandSource, + currentIndex = currentIndex + ) } else { if (repeatModeSetting == Player.REPEAT_MODE_ALL && currentPlaylist.isNotEmpty()) { currentIndex = currentPlaylist.lastIndex playAtIndex(currentIndex, useTrackTransitionFade = useTransitionFade) + emitPlaybackCommand( + type = "PREVIOUS", + source = commandSource, + currentIndex = currentIndex + ) } else { NPLogger.d("NERI-Player", "Already at the start of the playlist.") } @@ -2584,12 +2901,12 @@ object PlayerManager { // 本地没有,从网络获取 // B站歌曲在匹配网易云信息后应使用匹配到的歌曲 ID 获取翻译 - if (isYouTubeMusicSong(song)) { + if (isYouTubeMusicTrack(song)) { // YouTube Music 歌词暂无翻译来源 return emptyList() } - if (song.album.startsWith(BILI_SOURCE_TAG)) { + if (isBiliTrack(song)) { return when (song.matchedLyricSource) { MusicPlatform.CLOUD_MUSIC -> { val matchedId = song.matchedSongId?.toLongOrNull() @@ -2607,7 +2924,7 @@ object PlayerManager { /** 获取歌词,优先使用本地缓存 */ suspend fun getLyrics(song: SongItem): List { - if (isYouTubeMusicSong(song)) { + if (isYouTubeMusicTrack(song)) { return getYouTubeMusicLyrics(song) } // 最优先使用song.matchedLyric中的歌词 @@ -2632,20 +2949,25 @@ object PlayerManager { } // 最后回退到在线获取 - return if (isYouTubeMusicSong(song)) { + return if (isYouTubeMusicTrack(song)) { getYouTubeMusicLyrics(song) - } else if (song.album.startsWith(BILI_SOURCE_TAG)) { + } else if (isBiliTrack(song)) { emptyList() // B站暂时没有歌词API } else { getNeteaseLyrics(song.id) } } - fun playFromQueue(index: Int) { + fun playFromQueue( + index: Int, + commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL + ) { ensureInitialized() if (!initialized) return if (currentPlaylist.isEmpty()) return if (index !in currentPlaylist.indices) return + val targetSong = currentPlaylist[index] + if (shouldBlockLocalRoomControl(commandSource) || shouldBlockLocalSongSwitch(targetSong, commandSource)) return // 用户点选队列,视作新路径分叉 if (player.shuffleModeEnabled) { @@ -2655,7 +2977,12 @@ object PlayerManager { } currentIndex = index - playAtIndex(index) + playAtIndex(index, commandSource = commandSource) + emitPlaybackCommand( + type = "PLAY_FROM_QUEUE", + source = commandSource, + currentIndex = currentIndex + ) } /** @@ -3151,6 +3478,8 @@ private fun BiliVideoItem.toSongItem(): SongItem { album = PlayerManager.BILI_SOURCE_TAG, albumId = 0, durationMs = this.durationSec * 1000L, - coverUrl = this.coverUrl + coverUrl = this.coverUrl, + channelId = "bilibili", + audioId = this.id.toString() ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/ListenTogetherPreferences.kt b/app/src/main/java/moe/ouom/neriplayer/data/ListenTogetherPreferences.kt new file mode 100644 index 00000000..d849a05c --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/data/ListenTogetherPreferences.kt @@ -0,0 +1,152 @@ +package moe.ouom.neriplayer.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import moe.ouom.neriplayer.listentogether.buildDefaultListenTogetherNickname +import moe.ouom.neriplayer.listentogether.buildListenTogetherUserUuid +import moe.ouom.neriplayer.listentogether.isDefaultListenTogetherBaseUrl +import moe.ouom.neriplayer.listentogether.sanitizeListenTogetherNicknameOrNull + +private val Context.listenTogetherDataStore by preferencesDataStore("listen_together_prefs") + +object ListenTogetherPreferenceKeys { + val WORKER_BASE_URL = stringPreferencesKey("listen_together_worker_base_url") + val LAST_USER_ID = stringPreferencesKey("listen_together_last_user_id") + val LAST_USER_UUID = stringPreferencesKey("listen_together_last_user_uuid") + val LAST_NICKNAME = stringPreferencesKey("listen_together_last_nickname") + val ALLOW_MEMBER_CONTROL = booleanPreferencesKey("listen_together_allow_member_control") + val AUTO_PAUSE_ON_MEMBER_CHANGE = booleanPreferencesKey("listen_together_auto_pause_on_member_change") + val SHARE_AUDIO_LINKS = booleanPreferencesKey("listen_together_share_audio_links") +} + +class ListenTogetherPreferences(private val context: Context) { + val workerBaseUrlFlow: Flow = + context.listenTogetherDataStore.data.map { prefs -> + prefs[ListenTogetherPreferenceKeys.WORKER_BASE_URL] + ?.trim() + .orEmpty() + .takeUnless { isDefaultListenTogetherBaseUrl(it) } + .orEmpty() + } + + val userUuidFlow: Flow = + context.listenTogetherDataStore.data.map { prefs -> + prefs[ListenTogetherPreferenceKeys.LAST_USER_UUID] + ?.trim() + .orEmpty() + } + + val nicknameFlow: Flow = + context.listenTogetherDataStore.data.map { prefs -> + sanitizeListenTogetherNicknameOrNull(prefs[ListenTogetherPreferenceKeys.LAST_NICKNAME]) + ?: sanitizeListenTogetherNicknameOrNull(prefs[ListenTogetherPreferenceKeys.LAST_USER_ID]) + .orEmpty() + } + + val allowMemberControlFlow: Flow = + context.listenTogetherDataStore.data.map { + it[ListenTogetherPreferenceKeys.ALLOW_MEMBER_CONTROL] ?: true + } + + val autoPauseOnMemberChangeFlow: Flow = + context.listenTogetherDataStore.data.map { + it[ListenTogetherPreferenceKeys.AUTO_PAUSE_ON_MEMBER_CHANGE] ?: true + } + + val shareAudioLinksFlow: Flow = + context.listenTogetherDataStore.data.map { + it[ListenTogetherPreferenceKeys.SHARE_AUDIO_LINKS] ?: true + } + + suspend fun setWorkerBaseUrl(value: String) { + context.listenTogetherDataStore.edit { prefs -> + val normalized = value.trim() + if (normalized.isBlank() || isDefaultListenTogetherBaseUrl(normalized)) { + prefs.remove(ListenTogetherPreferenceKeys.WORKER_BASE_URL) + } else { + prefs[ListenTogetherPreferenceKeys.WORKER_BASE_URL] = normalized + } + } + } + + suspend fun setNickname(value: String) { + context.listenTogetherDataStore.edit { prefs -> + val normalized = value.trim() + if (normalized.isBlank()) { + prefs.remove(ListenTogetherPreferenceKeys.LAST_NICKNAME) + } else { + prefs[ListenTogetherPreferenceKeys.LAST_NICKNAME] = normalized + } + } + } + + suspend fun setUserUuid(value: String) { + context.listenTogetherDataStore.edit { prefs -> + val normalized = value.trim() + if (normalized.isBlank()) { + prefs.remove(ListenTogetherPreferenceKeys.LAST_USER_UUID) + } else { + prefs[ListenTogetherPreferenceKeys.LAST_USER_UUID] = normalized + } + } + } + + suspend fun getOrCreateUserUuid(): String { + var resolvedUserUuid = "" + context.listenTogetherDataStore.edit { prefs -> + resolvedUserUuid = prefs[ListenTogetherPreferenceKeys.LAST_USER_UUID] + ?.trim() + .orEmpty() + .ifBlank(::buildListenTogetherUserUuid) + prefs[ListenTogetherPreferenceKeys.LAST_USER_UUID] = resolvedUserUuid + } + return resolvedUserUuid + } + + suspend fun resetUserUuid(): String { + val nextUserUuid = buildListenTogetherUserUuid() + context.listenTogetherDataStore.edit { prefs -> + prefs[ListenTogetherPreferenceKeys.LAST_USER_UUID] = nextUserUuid + } + return nextUserUuid + } + + suspend fun getOrCreateNickname(): String { + var resolvedNickname = "" + context.listenTogetherDataStore.edit { prefs -> + resolvedNickname = prefs[ListenTogetherPreferenceKeys.LAST_NICKNAME] + ?.let(::sanitizeListenTogetherNicknameOrNull) + .orEmpty() + .ifBlank { + sanitizeListenTogetherNicknameOrNull(prefs[ListenTogetherPreferenceKeys.LAST_USER_ID]) + .orEmpty() + } + .ifBlank(::buildDefaultListenTogetherNickname) + prefs[ListenTogetherPreferenceKeys.LAST_NICKNAME] = resolvedNickname + } + return resolvedNickname + } + + suspend fun setAllowMemberControl(value: Boolean) { + context.listenTogetherDataStore.edit { prefs -> + prefs[ListenTogetherPreferenceKeys.ALLOW_MEMBER_CONTROL] = value + } + } + + suspend fun setAutoPauseOnMemberChange(value: Boolean) { + context.listenTogetherDataStore.edit { prefs -> + prefs[ListenTogetherPreferenceKeys.AUTO_PAUSE_ON_MEMBER_CHANGE] = value + } + } + + suspend fun setShareAudioLinks(value: Boolean) { + context.listenTogetherDataStore.edit { prefs -> + prefs[ListenTogetherPreferenceKeys.SHARE_AUDIO_LINKS] = value + } + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/data/LocalAudioImportManager.kt b/app/src/main/java/moe/ouom/neriplayer/data/LocalAudioImportManager.kt index 85abb635..426995c6 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/LocalAudioImportManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/LocalAudioImportManager.kt @@ -140,7 +140,9 @@ object LocalAudioImportManager { originalArtist = fallbackArtist, originalCoverUrl = null, localFileName = fileName, - localFilePath = source + localFilePath = source, + channelId = "local", + audioId = computeStableSongId(source).toString() ) songs += runCatching { @@ -210,7 +212,9 @@ object LocalAudioImportManager { originalName = baseSong.originalName?.takeIf { it.isNotBlank() } ?: safeTitle, originalArtist = baseSong.originalArtist?.takeIf { it.isNotBlank() } ?: safeArtist, localFileName = fileName, - localFilePath = source + localFilePath = source, + channelId = "local", + audioId = computeStableSongId(source).toString() ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/LocalMediaSupport.kt b/app/src/main/java/moe/ouom/neriplayer/data/LocalMediaSupport.kt index 458db420..cf4ce866 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/LocalMediaSupport.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/LocalMediaSupport.kt @@ -468,7 +468,9 @@ object LocalMediaSupport { originalArtist = details.originalArtist ?: details.artist, originalCoverUrl = details.coverUri, localFileName = details.displayName, - localFilePath = details.filePath + localFilePath = details.filePath, + channelId = "local", + audioId = stableId.toString() ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/github/GitHubSyncManager.kt b/app/src/main/java/moe/ouom/neriplayer/data/github/GitHubSyncManager.kt index 5f2bfcdf..6087c2f2 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/github/GitHubSyncManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/github/GitHubSyncManager.kt @@ -939,7 +939,11 @@ class GitHubSyncManager private constructor(context: Context) { a.originalArtist == b.originalArtist && a.originalCoverUrl == b.originalCoverUrl && a.originalLyric == b.originalLyric && - a.originalTranslatedLyric == b.originalTranslatedLyric + a.originalTranslatedLyric == b.originalTranslatedLyric && + a.channelId == b.channelId && + a.audioId == b.audioId && + a.subAudioId == b.subAudioId && + a.playlistContextId == b.playlistContextId } private suspend fun uploadLocalData( diff --git a/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataModels.kt b/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataModels.kt index 4742053a..098ca63e 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataModels.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataModels.kt @@ -118,7 +118,11 @@ data class SyncSong( @ProtoNumber(19) val originalArtist: String? = null, @ProtoNumber(20) val originalCoverUrl: String? = null, @ProtoNumber(21) val originalLyric: String? = null, - @ProtoNumber(22) val originalTranslatedLyric: String? = null + @ProtoNumber(22) val originalTranslatedLyric: String? = null, + @ProtoNumber(23) val channelId: String? = null, + @ProtoNumber(24) val audioId: String? = null, + @ProtoNumber(25) val subAudioId: String? = null, + @ProtoNumber(26) val playlistContextId: String? = null ) { companion object { fun fromSongItemOrNull(song: SongItem, context: Context? = null): SyncSong? { @@ -156,7 +160,11 @@ data class SyncSong( originalArtist = song.originalArtist, originalCoverUrl = syncOriginalCoverUrl, originalLyric = song.originalLyric, - originalTranslatedLyric = song.originalTranslatedLyric + originalTranslatedLyric = song.originalTranslatedLyric, + channelId = song.channelId, + audioId = song.audioId, + subAudioId = song.subAudioId, + playlistContextId = song.playlistContextId ) } } @@ -185,7 +193,11 @@ data class SyncSong( originalArtist = originalArtist, originalCoverUrl = originalCoverUrl, originalLyric = originalLyric, - originalTranslatedLyric = originalTranslatedLyric + originalTranslatedLyric = originalTranslatedLyric, + channelId = channelId, + audioId = audioId, + subAudioId = subAudioId, + playlistContextId = playlistContextId ) } } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataSerializer.kt b/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataSerializer.kt index af5aa995..dfb8911c 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataSerializer.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/github/SyncDataSerializer.kt @@ -289,7 +289,11 @@ object SyncDataSerializer { originalArtist = originalArtist, originalCoverUrl = originalCoverUrl, originalLyric = originalLyric, - originalTranslatedLyric = originalTranslatedLyric + originalTranslatedLyric = originalTranslatedLyric, + channelId = null, + audioId = null, + subAudioId = null, + playlistContextId = null ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt new file mode 100644 index 00000000..e730569d --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt @@ -0,0 +1,149 @@ +package moe.ouom.neriplayer.listentogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +data class ListenTogetherServerTestResult( + val ok: Boolean, + val message: String +) + +class ListenTogetherApi( + private val okHttpClient: OkHttpClient +) { + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + explicitNulls = false + } + + suspend fun createRoom( + baseUrl: String, + userUuid: String, + nickname: String, + initialSnapshot: ListenTogetherInitialSnapshot + ): ListenTogetherRoomResponse { + return post( + url = "${baseUrl.normalizeBaseUrl()}/api/rooms", + body = ListenTogetherCreateRoomRequest( + userUuid = userUuid, + nickname = nickname, + initialSnapshot = initialSnapshot + ) + ) + } + + suspend fun joinRoom( + baseUrl: String, + roomId: String, + userUuid: String, + nickname: String + ): ListenTogetherRoomResponse { + return post( + url = "${baseUrl.normalizeBaseUrl()}/api/rooms/$roomId/join", + body = ListenTogetherJoinRoomRequest( + userUuid = userUuid, + nickname = nickname + ) + ) + } + + suspend fun getRoomState( + baseUrl: String, + roomId: String + ): ListenTogetherStateResponse { + return get("${baseUrl.normalizeBaseUrl()}/api/rooms/$roomId/state") + } + + suspend fun sendControlEvent( + baseUrl: String, + roomId: String, + token: String, + event: ListenTogetherEvent + ): ListenTogetherControlResponse { + return post( + url = "${baseUrl.normalizeBaseUrl()}/api/rooms/$roomId/control", + body = event, + bearerToken = token + ) + } + + suspend fun testServerAvailability(baseUrl: String): ListenTogetherServerTestResult = withContext(Dispatchers.IO) { + val normalizedBaseUrl = baseUrl.normalizeBaseUrl() + val request = Request.Builder() + .url("$normalizedBaseUrl/api/rooms/ABCDEF/state") + .get() + .build() + runCatching { + okHttpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + val looksLikeListenTogetherService = + body.contains("\"ok\"", ignoreCase = true) || + body.contains("room not initialized", ignoreCase = true) || + body.contains("not found", ignoreCase = true) + if (looksLikeListenTogetherService) { + ListenTogetherServerTestResult( + ok = true, + message = "reachable" + ) + } else { + ListenTogetherServerTestResult( + ok = false, + message = "invalid_response" + ) + } + } + }.getOrElse { + ListenTogetherServerTestResult( + ok = false, + message = it.message ?: it.javaClass.simpleName + ) + } + } + + private suspend inline fun get(url: String): T = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .get() + .build() + okHttpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + error("ListenTogether GET failed (${response.code}): $body") + } + json.decodeFromString(body) + } + } + + private suspend inline fun post( + url: String, + body: RequestBodyT, + bearerToken: String? = null + ): ResponseT = withContext(Dispatchers.IO) { + val requestBuilder = Request.Builder() + .url(url) + .post( + json.encodeToString(body) + .toRequestBody("application/json; charset=utf-8".toMediaType()) + ) + bearerToken?.takeIf { it.isNotBlank() }?.let { + requestBuilder.header("Authorization", "Bearer $it") + } + okHttpClient.newCall(requestBuilder.build()).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + error("ListenTogether POST failed (${response.code}): $responseBody") + } + json.decodeFromString(responseBody) + } + } +} + +internal fun String.normalizeBaseUrl(): String = trim().trimEnd('/') diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt new file mode 100644 index 00000000..4fc6b457 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt @@ -0,0 +1,95 @@ +package moe.ouom.neriplayer.listentogether + +import android.net.Uri +import java.util.UUID + +const val DEFAULT_LISTEN_TOGETHER_BASE_URL = + "https://neriplayer.hancat.work/" + +private const val LISTEN_TOGETHER_INVITE_SCHEME = "neriplayer" +private const val LISTEN_TOGETHER_INVITE_HOST = "listen-together" +private const val LISTEN_TOGETHER_INVITE_JOIN_PATH = "join" +private val LISTEN_TOGETHER_INVITE_REGEX = Regex( + pattern = """neriplayer://listen-together/join\?[^\s]+""", + option = RegexOption.IGNORE_CASE +) + +data class ListenTogetherInvite( + val roomId: String, + val inviterNickname: String? = null, + val baseUrl: String? = null +) { + val signature: String + get() = listOf(roomId, inviterNickname.orEmpty(), baseUrl.orEmpty()).joinToString("|") +} + +fun buildListenTogetherUserUuid(): String { + return UUID.randomUUID().toString() +} + +fun buildDefaultListenTogetherNickname(): String { + return "Neri${UUID.randomUUID().toString().replace("-", "").take(6).uppercase()}" +} + +fun buildListenTogetherInviteUri( + roomId: String, + inviterNickname: String? = null, + baseUrl: String? = null +): String { + val normalizedRoomId = requireValidListenTogetherRoomId(roomId) + val normalizedBaseUrl = baseUrl + ?.takeIf { it.isNotBlank() } + ?.normalizeBaseUrl() + ?.takeUnless { isDefaultListenTogetherBaseUrl(it) } + return Uri.Builder() + .scheme(LISTEN_TOGETHER_INVITE_SCHEME) + .authority(LISTEN_TOGETHER_INVITE_HOST) + .appendPath(LISTEN_TOGETHER_INVITE_JOIN_PATH) + .appendQueryParameter("roomId", normalizedRoomId) + .apply { + inviterNickname?.takeIf { it.isNotBlank() }?.let { + appendQueryParameter("inviter", requireValidListenTogetherNickname(it)) + } + normalizedBaseUrl?.let { + appendQueryParameter("baseUrl", it) + } + } + .build() + .toString() +} + +fun resolveListenTogetherBaseUrl(value: String?): String { + val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizeBaseUrl() + return normalized ?: DEFAULT_LISTEN_TOGETHER_BASE_URL.normalizeBaseUrl() +} + +fun isDefaultListenTogetherBaseUrl(value: String?): Boolean { + val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizeBaseUrl() + return normalized == DEFAULT_LISTEN_TOGETHER_BASE_URL.normalizeBaseUrl() +} + +fun parseListenTogetherInvite(uri: Uri?): ListenTogetherInvite? { + uri ?: return null + if (!uri.scheme.equals(LISTEN_TOGETHER_INVITE_SCHEME, ignoreCase = true)) return null + if (!uri.host.equals(LISTEN_TOGETHER_INVITE_HOST, ignoreCase = true)) return null + val pathSegments = uri.pathSegments + if (pathSegments.firstOrNull() != LISTEN_TOGETHER_INVITE_JOIN_PATH) return null + val roomId = normalizeListenTogetherRoomId(uri.getQueryParameter("roomId").orEmpty()) + if (validateListenTogetherRoomId(roomId) != null) return null + val inviterNickname = uri.getQueryParameter("inviter") + ?.trim() + ?.takeIf { it.isNotBlank() && validateListenTogetherNickname(it) == null } + return ListenTogetherInvite( + roomId = roomId, + inviterNickname = inviterNickname, + baseUrl = uri.getQueryParameter("baseUrl")?.trim()?.takeIf { it.isNotBlank() } + ) +} + +fun parseListenTogetherInvite(rawText: String?): ListenTogetherInvite? { + val text = rawText?.trim().orEmpty() + if (text.isBlank()) return null + parseListenTogetherInvite(Uri.parse(text))?.let { return it } + val match = LISTEN_TOGETHER_INVITE_REGEX.find(text)?.value ?: return null + return parseListenTogetherInvite(Uri.parse(match)) +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherMapper.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherMapper.kt new file mode 100644 index 00000000..d20105d5 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherMapper.kt @@ -0,0 +1,227 @@ +package moe.ouom.neriplayer.listentogether + +import android.net.Uri +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.data.LocalSongSupport +import moe.ouom.neriplayer.data.buildYouTubeMusicMediaUri +import moe.ouom.neriplayer.data.extractYouTubeMusicVideoId +import moe.ouom.neriplayer.data.stableYouTubeMusicId +import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +import moe.ouom.neriplayer.util.NPLogger + +fun SongItem.toListenTogetherTrackOrNull(includeLocal: Boolean = false): ListenTogetherTrack? { + val channel = resolvedChannelId() ?: return null + if (channel == ListenTogetherChannels.LOCAL && !includeLocal) { + return null + } + + val audio = resolvedAudioId() ?: return null + val subAudio = resolvedSubAudioId() + val playlistContext = resolvedPlaylistContextId() + return ListenTogetherTrack( + stableKey = buildStableTrackKey(channel, audio, subAudio, playlistContext), + channelId = channel, + audioId = audio, + subAudioId = subAudio, + playlistContextId = playlistContext, + mediaUri = mediaUri, + streamUrl = streamUrl, + name = customName ?: name, + artist = customArtist ?: artist, + album = album, + durationMs = durationMs, + coverUrl = customCoverUrl ?: coverUrl + ) +} + +fun ListenTogetherTrack.toSongItem(): SongItem { + val trustedStreamUrl = trustedListenTogetherStreamUrl(channelId, streamUrl) + return when (channelId) { + ListenTogetherChannels.YOUTUBE_MUSIC -> { + val playlistId = playlistContextId?.takeIf { it.isNotBlank() } + SongItem( + id = stableYouTubeMusicId(audioId), + name = name, + artist = artist, + album = album.orEmpty(), + albumId = stableYouTubeMusicId(playlistId ?: audioId), + durationMs = durationMs, + coverUrl = coverUrl, + mediaUri = mediaUri ?: buildYouTubeMusicMediaUri(audioId, playlistId), + originalName = name, + originalArtist = artist, + originalCoverUrl = coverUrl, + channelId = channelId, + audioId = audioId, + subAudioId = subAudioId, + playlistContextId = playlistContextId, + streamUrl = trustedStreamUrl + ) + } + + ListenTogetherChannels.BILIBILI -> { + val songId = audioId.toLongOrNull() ?: stableKey.hashCode().toLong() + val albumTag = subAudioId?.takeIf { it.isNotBlank() } + ?.let { "${PlayerManager.BILI_SOURCE_TAG}|$it" } + ?: PlayerManager.BILI_SOURCE_TAG + SongItem( + id = songId, + name = name, + artist = artist, + album = albumTag, + albumId = 0L, + durationMs = durationMs, + coverUrl = coverUrl, + channelId = channelId, + audioId = audioId, + subAudioId = subAudioId, + playlistContextId = playlistContextId, + streamUrl = trustedStreamUrl + ) + } + + ListenTogetherChannels.LOCAL -> { + val songId = audioId.toLongOrNull() ?: stableKey.hashCode().toLong() + SongItem( + id = songId, + name = name, + artist = artist, + album = album ?: LocalSongSupport.LOCAL_ALBUM_IDENTITY, + albumId = 0L, + durationMs = durationMs, + coverUrl = coverUrl, + mediaUri = mediaUri, + originalName = name, + originalArtist = artist, + originalCoverUrl = coverUrl, + localFilePath = mediaUri, + channelId = channelId, + audioId = audioId, + subAudioId = subAudioId, + playlistContextId = playlistContextId, + streamUrl = trustedStreamUrl + ) + } + + else -> { + val songId = audioId.toLongOrNull() ?: stableKey.hashCode().toLong() + SongItem( + id = songId, + name = name, + artist = artist, + album = album.orEmpty(), + albumId = 0L, + durationMs = durationMs, + coverUrl = coverUrl, + channelId = ListenTogetherChannels.NETEASE, + audioId = audioId, + subAudioId = subAudioId, + playlistContextId = playlistContextId, + streamUrl = trustedStreamUrl + ) + } + } +} + +fun ListenTogetherTrack.withStreamUrl(streamUrl: String?): ListenTogetherTrack { + val normalizedStreamUrl = trustedListenTogetherStreamUrl(channelId, streamUrl) + if (normalizedStreamUrl == this.streamUrl) return this + return copy(streamUrl = normalizedStreamUrl) +} + +private fun trustedListenTogetherStreamUrl( + channelId: String, + streamUrl: String? +): String? { + val candidate = streamUrl?.trim().orEmpty() + if (candidate.isBlank()) return null + val uri = runCatching { Uri.parse(candidate) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase().orEmpty() + if (scheme != "https" && scheme != "http") return null + val host = uri.host?.lowercase().orEmpty() + if (host.isBlank()) return null + val trusted = when (channelId) { + ListenTogetherChannels.NETEASE -> host == "music.126.net" || host.endsWith(".music.126.net") + ListenTogetherChannels.BILIBILI -> { + host == "bilivideo.com" || + host.endsWith(".bilivideo.com") || + host == "bilivideo.cn" || + host.endsWith(".bilivideo.cn") || + host == "hdslb.com" || + host.endsWith(".hdslb.com") + } + ListenTogetherChannels.YOUTUBE_MUSIC -> { + host == "googlevideo.com" || + host.endsWith(".googlevideo.com") || + host == "youtube.com" || + host.endsWith(".youtube.com") || + host == "youtube-nocookie.com" || + host.endsWith(".youtube-nocookie.com") + } + else -> false + } + if (!trusted) { + NPLogger.w( + "NERI-ListenTogether", + "Blocked non-whitelisted streamUrl for listen together: channelId=$channelId, host=$host" + ) + return null + } + return candidate +} + +fun buildStableTrackKey( + channelId: String, + audioId: String, + subAudioId: String? = null, + playlistContextId: String? = null +): String { + return when (channelId) { + ListenTogetherChannels.BILIBILI -> { + listOf(channelId, audioId, subAudioId).filterNot { it.isNullOrBlank() }.joinToString(":") + } + + ListenTogetherChannels.YOUTUBE_MUSIC -> { + listOf(channelId, audioId, playlistContextId).filterNot { it.isNullOrBlank() }.joinToString(":") + } + + else -> "$channelId:$audioId" + } +} + +fun SongItem.resolvedChannelId(): String? { + channelId?.takeIf { it.isNotBlank() }?.let { return it } + return when { + LocalSongSupport.isLocalSong(this, null) -> ListenTogetherChannels.LOCAL + !extractYouTubeMusicVideoId(mediaUri).isNullOrBlank() -> ListenTogetherChannels.YOUTUBE_MUSIC + album.startsWith(PlayerManager.BILI_SOURCE_TAG) -> ListenTogetherChannels.BILIBILI + else -> ListenTogetherChannels.NETEASE + } +} + +fun SongItem.resolvedAudioId(): String? { + audioId?.takeIf { it.isNotBlank() }?.let { return it } + return when (resolvedChannelId()) { + ListenTogetherChannels.YOUTUBE_MUSIC -> extractYouTubeMusicVideoId(mediaUri) + else -> id.toString() + } +} + +fun SongItem.resolvedSubAudioId(): String? { + subAudioId?.takeIf { it.isNotBlank() }?.let { return it } + if (resolvedChannelId() != ListenTogetherChannels.BILIBILI) { + return null + } + return album.substringAfter('|', "").takeIf { it.isNotBlank() } +} + +fun SongItem.resolvedPlaylistContextId(): String? { + playlistContextId?.takeIf { it.isNotBlank() }?.let { return it } + if (resolvedChannelId() != ListenTogetherChannels.YOUTUBE_MUSIC) { + return null + } + return mediaUri + ?.let(Uri::parse) + ?.getQueryParameter("playlistId") + ?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherProtocol.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherProtocol.kt new file mode 100644 index 00000000..5d9c5be8 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherProtocol.kt @@ -0,0 +1,231 @@ +package moe.ouom.neriplayer.listentogether + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl.Companion.toHttpUrl + +object ListenTogetherChannels { + const val NETEASE = "netease" + const val BILIBILI = "bilibili" + const val YOUTUBE_MUSIC = "youtubeMusic" + const val LOCAL = "local" +} + +@Serializable +data class ListenTogetherTrack( + val stableKey: String, + val channelId: String, + val audioId: String, + val subAudioId: String? = null, + val playlistContextId: String? = null, + val mediaUri: String? = null, + val streamUrl: String? = null, + val name: String, + val artist: String, + val album: String? = null, + val durationMs: Long = 0L, + val coverUrl: String? = null +) + +@Serializable +data class ListenTogetherRoomSettings( + val allowMemberControl: Boolean = true, + val autoPauseOnMemberChange: Boolean = true, + val shareAudioLinks: Boolean = true +) + +@Serializable +data class ListenTogetherMember( + val userUuid: String = "", + val nickname: String = "", + val userId: String? = null, + val role: String, + val joinedAt: Long +) + +@Serializable +data class ListenTogetherPlaybackState( + val state: String = "paused", + val basePositionMs: Long = 0L, + val baseTimestampMs: Long = 0L, + val playbackRate: Double = 1.0 +) + +@Serializable +data class ListenTogetherRoomState( + val roomId: String, + val version: Long, + val schemaVersion: Int = 1, + val controllerUserUuid: String? = null, + val controllerUserId: String? = null, + val controllerHeartbeatAt: Long? = null, + val settings: ListenTogetherRoomSettings = ListenTogetherRoomSettings(), + val members: List = emptyList(), + val queue: List = emptyList(), + val currentIndex: Int = 0, + val track: ListenTogetherTrack? = null, + val playback: ListenTogetherPlaybackState = ListenTogetherPlaybackState(), + val controllerOfflineSince: Long? = null, + val roomStatus: String = "active", + val closedReason: String? = null, + val updatedAt: Long = 0L +) + +@Serializable +data class ListenTogetherCause( + val userUuid: String? = null, + val userId: String? = null, + val nickname: String? = null, + val eventId: String? = null, + val type: String? = null +) + +@Serializable +data class ListenTogetherInitialSnapshot( + val queue: List = emptyList(), + val currentIndex: Int = 0, + val track: ListenTogetherTrack? = null, + val settings: ListenTogetherRoomSettings = ListenTogetherRoomSettings(), + val isPlaying: Boolean = false, + val positionMs: Long = 0L +) + +@Serializable +data class ListenTogetherCreateRoomRequest( + val userUuid: String, + val nickname: String, + val initialSnapshot: ListenTogetherInitialSnapshot +) + +@Serializable +data class ListenTogetherJoinRoomRequest( + val userUuid: String, + val nickname: String +) + +@Serializable +data class ListenTogetherRoomResponse( + val ok: Boolean, + val roomId: String? = null, + val userUuid: String? = null, + val userId: String? = null, + val nickname: String? = null, + val role: String? = null, + val autoPauseOnJoin: Boolean = false, + val token: String? = null, + val state: ListenTogetherRoomState? = null, + val wsUrl: String? = null, + val error: String? = null +) + +@Serializable +data class ListenTogetherStateResponse( + val ok: Boolean, + val state: ListenTogetherRoomState? = null, + val expectedPositionMs: Long? = null, + val autoPauseOnJoin: Boolean = false, + val error: String? = null +) + +@Serializable +data class ListenTogetherAppliedEvent( + val type: String, + val roomId: String? = null, + val version: Long? = null, + val state: ListenTogetherRoomState? = null, + val expectedPositionMs: Long? = null, + val causedBy: ListenTogetherCause? = null +) + +@Serializable +data class ListenTogetherControlResponse( + val ok: Boolean, + val applied: ListenTogetherAppliedEvent? = null, + val error: String? = null +) + +@Serializable +data class ListenTogetherEvent( + val type: String, + val eventId: String? = null, + val clientTimeMs: Long? = null, + val positionMs: Long? = null, + val currentIndex: Int? = null, + val track: ListenTogetherTrack? = null, + val queue: List? = null, + val roomSettings: ListenTogetherRoomSettings? = null, + val shouldPlay: Boolean? = null, + val state: String? = null, + val requestTrackStableKey: String? = null +) + +@Serializable +data class ListenTogetherSocketEnvelope( + val type: String, + val sessionId: String? = null, + val userUuid: String? = null, + val userId: String? = null, + val nickname: String? = null, + val role: String? = null, + val autoPauseOnJoin: Boolean = false, + val state: ListenTogetherRoomState? = null, + val expectedPositionMs: Long? = null, + val nowMs: Long? = null, + val ok: Boolean? = null, + val result: ListenTogetherControlResponse? = null, + val message: String? = null, + val roomId: String? = null, + val version: Long? = null, + val causedBy: ListenTogetherCause? = null, + val track: ListenTogetherTrack? = null, + val queue: List? = null, + val positionMs: Long? = null, + val currentIndex: Int? = null, + val requestTrackStableKey: String? = null, + val shouldPlay: Boolean? = null, + val stateName: String? = null, + val clientTimeMs: Long? = null, + val requestSequence: Long? = null +) + +object ListenTogetherRoomStatuses { + const val ACTIVE = "active" + const val CONTROLLER_OFFLINE = "controller_offline" + const val CLOSED = "closed" +} + +@Serializable +enum class ListenTogetherConnectionState { + @SerialName("disconnected") + DISCONNECTED, + @SerialName("connecting") + CONNECTING, + @SerialName("connected") + CONNECTED +} + +data class ListenTogetherSessionState( + val baseUrl: String? = null, + val roomId: String? = null, + val userUuid: String? = null, + val nickname: String? = null, + val role: String? = null, + val token: String? = null, + val wsUrl: String? = null, + val connectionState: ListenTogetherConnectionState = ListenTogetherConnectionState.DISCONNECTED, + val lastError: String? = null, + val expectedPositionMs: Long? = null, + val roomNotice: String? = null +) + +fun buildListenTogetherWsUrl(baseUrl: String, roomId: String, token: String): String { + val normalizedBase = baseUrl.normalizeBaseUrl() + val url = normalizedBase.toHttpUrl().newBuilder() + .encodedPath("/api/rooms/$roomId/ws") + .setQueryParameter("token", token) + .build() + return url.newBuilder() + .scheme(if (url.isHttps) "wss" else "ws") + .build() + .toString() +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt new file mode 100644 index 00000000..b5b972c9 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt @@ -0,0 +1,1736 @@ +package moe.ouom.neriplayer.listentogether + +import android.os.Looper +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import moe.ouom.neriplayer.core.player.PlaybackCommand +import moe.ouom.neriplayer.core.player.PlaybackCommandSource +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +import moe.ouom.neriplayer.util.NPLogger +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +class ListenTogetherSessionManager( + private val api: ListenTogetherApi, + private val webSocketClient: ListenTogetherWebSocketClient +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var heartbeatJob: Job? = null + private var reconnectJob: Job? = null + private var membershipRecoveryJob: Job? = null + + @Volatile + private var started = false + + private val recentOutboundEventIds = LinkedHashSet() + private val recentInboundEventIds = LinkedHashSet() + private val recentEventLock = Any() + @Volatile + private var lastOutboundSyncAtMs: Long = 0L + @Volatile + private var lastRequestedLinkStableKey: String? = null + @Volatile + private var lastRequestedLinkAtElapsedMs: Long = 0L + @Volatile + private var lastAppliedRoomVersion: Long = -1L + @Volatile + private var lastControllerLocalControlAtElapsedMs: Long = 0L + @Volatile + private var reconnectEnabled = false + @Volatile + private var reconnectAttempt = 0 + @Volatile + private var lastHandledForwardedRequestSequence: Long = 0L + @Volatile + private var pendingStateRefreshAfterReconnect = false + + private val _sessionState = MutableStateFlow(ListenTogetherSessionState()) + val sessionState: StateFlow = _sessionState.asStateFlow() + + private val _roomState = MutableStateFlow(null) + val roomState: StateFlow = _roomState.asStateFlow() + + init { + start() + } + + fun start() { + if (started) return + started = true + NPLogger.d(TAG, "start(): subscribe playbackCommandFlow") + scope.launch { + PlayerManager.playbackCommandFlow.collectLatest(::handleLocalPlaybackCommand) + } + scope.launch { + PlayerManager.currentMediaUrlFlow.collectLatest(::handleResolvedStreamUrlChanged) + } + } + + suspend fun createRoom( + baseUrl: String, + userUuid: String, + nickname: String, + queue: List, + currentIndex: Int, + positionMs: Long, + isPlaying: Boolean, + roomSettings: ListenTogetherRoomSettings = ListenTogetherRoomSettings() + ): ListenTogetherRoomResponse { + val validatedUserUuid = requireValidListenTogetherUserUuid(userUuid) + val validatedNickname = requireValidListenTogetherNickname(nickname) + val (queueTracks, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = currentIndex, + roomSettings = roomSettings + ) + NPLogger.d( + TAG, + "createRoom(): baseUrl=$baseUrl, userUuid=$validatedUserUuid, nickname=$validatedNickname, queueSize=${queue.size}, shareableQueueSize=${queueTracks.size}, currentIndex=$currentIndex, resolvedCurrentIndex=$resolvedCurrentIndex, isPlaying=$isPlaying, positionMs=$positionMs" + ) + val response = api.createRoom( + baseUrl = baseUrl, + userUuid = validatedUserUuid, + nickname = validatedNickname, + initialSnapshot = ListenTogetherInitialSnapshot( + queue = queueTracks, + currentIndex = resolvedCurrentIndex, + track = queueTracks.getOrNull(resolvedCurrentIndex), + settings = roomSettings.normalized(), + isPlaying = isPlaying, + positionMs = positionMs.coerceAtLeast(0L) + ) + ) + updateSession(baseUrl, response) + NPLogger.d( + TAG, + "createRoom(): ok=${response.ok}, roomId=${response.roomId}, role=${response.role}, wsUrl=${response.wsUrl}" + ) + return response + } + + suspend fun joinRoom( + baseUrl: String, + roomId: String, + userUuid: String, + nickname: String + ): ListenTogetherRoomResponse { + val validatedRoomId = requireValidListenTogetherRoomId(roomId) + val validatedUserUuid = requireValidListenTogetherUserUuid(userUuid) + val validatedNickname = requireValidListenTogetherNickname(nickname) + NPLogger.d(TAG, "joinRoom(): baseUrl=$baseUrl, roomId=$validatedRoomId, userUuid=$validatedUserUuid, nickname=$validatedNickname") + val response = api.joinRoom(baseUrl, validatedRoomId, validatedUserUuid, validatedNickname) + updateSession(baseUrl, response) + NPLogger.d( + TAG, + "joinRoom(): ok=${response.ok}, roomId=${response.roomId}, role=${response.role}, wsUrl=${response.wsUrl}" + ) + return response + } + + suspend fun refreshRoomState(baseUrl: String, roomId: String): ListenTogetherStateResponse { + val validatedRoomId = requireValidListenTogetherRoomId(roomId) + NPLogger.d(TAG, "refreshRoomState(): baseUrl=$baseUrl, roomId=$validatedRoomId") + val response = api.getRoomState(baseUrl, validatedRoomId) + response.state?.let { + val resolvedState = resolveJoinAutoPauseState( + state = it, + autoPauseOnJoin = response.autoPauseOnJoin, + role = _sessionState.value.role + ) + applyRoomState(resolvedState, response.expectedPositionMs) + if (!isCurrentUserController()) { + applyRoomStateToPlayer( + resolvedState, + causeType = if (response.autoPauseOnJoin) "JOIN_AUTO_PAUSE" else null, + expectedPositionMs = response.expectedPositionMs + ) + maybeRequestControllerLink(resolvedState, "refresh_room_state") + } + } + NPLogger.d( + TAG, + "refreshRoomState(): ok=${response.ok}, version=${response.state?.version}, expectedPositionMs=${response.expectedPositionMs}" + ) + return response + } + + fun connectWebSocket() { + reconnectEnabled = true + reconnectJob?.cancel() + reconnectJob = null + val wsUrl = _sessionState.value.wsUrl ?: return + NPLogger.d(TAG, "connectWebSocket(): wsUrl=$wsUrl") + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.CONNECTING, + lastError = null + ) + webSocketClient.connect( + wsUrl = wsUrl, + listener = object : ListenTogetherWebSocketClient.Listener { + override fun onOpen() { + NPLogger.d(TAG, "websocket.onOpen()") + val shouldRefreshState = pendingStateRefreshAfterReconnect + reconnectAttempt = 0 + reconnectJob?.cancel() + reconnectJob = null + startHeartbeat() + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.CONNECTED, + lastError = null + ) + pendingStateRefreshAfterReconnect = false + if (shouldRefreshState) { + scope.launch { + refreshRoomStateAfterReconnect("socket_open") + } + } + _roomState.value?.let { currentState -> + maybeRequestControllerLink(currentState, "socket_open") + } + publishControllerHeartbeatIfNeeded(force = true, reason = "socket_open") + } + + override fun onMessage(message: ListenTogetherSocketEnvelope) { + NPLogger.d( + TAG, + "websocket.onMessage(): type=${message.type}, roomId=${message.roomId ?: message.state?.roomId}, version=${message.version ?: message.state?.version}, causedBy=${message.causedBy?.type}:${message.causedBy?.eventId}, ok=${message.ok}, message=${message.message}, resultError=${message.result?.error}" + ) + when (message.type) { + "welcome", + "room_state_updated" -> handleSocketRoomState(message) + "link_requested" -> handleLinkRequested(message) + "member_control_requested" -> handleMemberControlRequested(message) + "room_suspended" -> handleRoomSuspended(message) + "room_resumed" -> handleRoomResumed(message) + "room_closed" -> handleRoomClosed(message) + "control_result", + "ack" -> { + val error = message.result?.error + val applied = message.result?.applied + if ( + error.isNullOrBlank() && + applied?.state != null && + ( + applied.causedBy?.type == "UPDATE_SETTINGS" || + ( + applied.causedBy?.type?.startsWith("REQUEST_") == true && + applied.causedBy.userUuid == _sessionState.value.userUuid + ) + ) && + applied.state != null + ) { + NPLogger.d( + TAG, + "websocket.controlResult(): apply committed state locally, type=${applied.causedBy?.type}, version=${applied.version}" + ) + applyRoomState(applied.state, applied.expectedPositionMs) + if (!isCurrentUserController()) { + applyRoomStateToPlayer( + applied.state, + applied.causedBy?.type, + applied.expectedPositionMs + ) + } + } + if (!error.isNullOrBlank() || message.ok == false) { + val resolvedError = error + ?: message.message + ?: "control event rejected" + NPLogger.w(TAG, "websocket.controlResult(): $resolvedError") + _sessionState.value = _sessionState.value.copy(lastError = resolvedError) + if (handleTerminalReconnectFailure(resolvedError, "control_result")) { + return + } + maybeRecoverFromFatalMembershipError( + errorMessage = resolvedError, + reason = "control_result" + ) + } + } + + "error" -> { + val resolvedError = message.message ?: "socket error" + NPLogger.w(TAG, "websocket.error(): $resolvedError") + _sessionState.value = _sessionState.value.copy(lastError = resolvedError) + if (handleTerminalReconnectFailure(resolvedError, "socket_error")) { + return + } + maybeRecoverFromFatalMembershipError( + errorMessage = resolvedError, + reason = "socket_error" + ) + } + + "pong" -> { + _sessionState.value = _sessionState.value.copy(lastError = null) + } + } + } + + override fun onClosed(code: Int, reason: String) { + stopHeartbeat() + NPLogger.w(TAG, "websocket.onClosed(): code=$code, reason=$reason") + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.DISCONNECTED, + lastError = reason.takeIf { it.isNotBlank() } + ) + if (handleTerminalReconnectFailure(reason, "socket_closed:$code")) { + return + } + scheduleReconnect("closed:$code:${reason.ifBlank { "unknown" }}") + } + + override fun onFailure(error: Throwable) { + stopHeartbeat() + NPLogger.e(TAG, "websocket.onFailure(): ${error.message}", error) + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.DISCONNECTED, + lastError = error.message ?: error.javaClass.simpleName + ) + if (handleTerminalReconnectFailure(error.message, "socket_failure")) { + return + } + scheduleReconnect("failure:${error.message ?: error.javaClass.simpleName}") + } + + override fun onProtocolError(rawText: String, error: Throwable) { + NPLogger.w( + "NERI-ListenTogether", + "WebSocket protocol decode failed: ${error.message}, raw=${rawText.take(512)}" + ) + _sessionState.value = _sessionState.value.copy( + lastError = "Protocol: ${error.message ?: error.javaClass.simpleName}" + ) + } + } + ) + } + + fun disconnectWebSocket() { + reconnectEnabled = false + reconnectAttempt = 0 + pendingStateRefreshAfterReconnect = false + reconnectJob?.cancel() + reconnectJob = null + stopHeartbeat() + lastOutboundSyncAtMs = 0L + lastRequestedLinkStableKey = null + lastRequestedLinkAtElapsedMs = 0L + lastAppliedRoomVersion = -1L + lastControllerLocalControlAtElapsedMs = 0L + lastHandledForwardedRequestSequence = 0L + PlayerManager.resetListenTogetherSyncPlaybackRate() + NPLogger.d(TAG, "disconnectWebSocket()") + webSocketClient.disconnect() + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.DISCONNECTED, + roomNotice = null + ) + } + + fun leaveRoom() { + reconnectEnabled = false + reconnectAttempt = 0 + pendingStateRefreshAfterReconnect = false + reconnectJob?.cancel() + reconnectJob = null + stopHeartbeat() + lastOutboundSyncAtMs = 0L + lastRequestedLinkStableKey = null + lastRequestedLinkAtElapsedMs = 0L + lastAppliedRoomVersion = -1L + lastControllerLocalControlAtElapsedMs = 0L + lastHandledForwardedRequestSequence = 0L + PlayerManager.resetListenTogetherSyncPlaybackRate() + NPLogger.d(TAG, "leaveRoom(): roomId=${_sessionState.value.roomId}, role=${_sessionState.value.role}") + webSocketClient.disconnect() + synchronized(recentEventLock) { + recentOutboundEventIds.clear() + recentInboundEventIds.clear() + } + val snapshot = _sessionState.value + _roomState.value = null + _sessionState.value = ListenTogetherSessionState( + baseUrl = snapshot.baseUrl, + userUuid = snapshot.userUuid, + nickname = snapshot.nickname, + connectionState = ListenTogetherConnectionState.DISCONNECTED + ) + } + + fun sendPing(): Boolean = webSocketClient.sendPing() + + suspend fun sendControlEvent(event: ListenTogetherEvent): ListenTogetherControlResponse { + val snapshot = _sessionState.value + val baseUrl = snapshot.baseUrl ?: error("baseUrl missing") + val roomId = snapshot.roomId ?: error("roomId missing") + val token = snapshot.token ?: error("token missing") + return api.sendControlEvent(baseUrl, roomId, token, event) + } + + fun sendControlEventOverWebSocket(event: ListenTogetherEvent): Boolean { + return webSocketClient.sendEvent(event) + } + + suspend fun updateRoomSettings(settings: ListenTogetherRoomSettings): ListenTogetherControlResponse { + val event = ListenTogetherEvent( + type = "UPDATE_SETTINGS", + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + roomSettings = settings.normalized() + ) + markOutboundEvent(event.eventId) + noteOutboundSync() + return if (sendControlEventPureWebSocket(event, "update_settings")) { + ListenTogetherControlResponse(ok = true) + } else { + ListenTogetherControlResponse(ok = false, error = "websocket unavailable") + } + } + + fun applyRoomStateToPlayer( + state: ListenTogetherRoomState, + causeType: String? = null, + expectedPositionMs: Long? = null + ) { + if (Looper.myLooper() != Looper.getMainLooper()) { + NPLogger.d( + TAG, + "applyRoomStateToPlayer(): repost to main thread, roomId=${state.roomId}, version=${state.version}, causeType=$causeType" + ) + mainScope.launch { + applyRoomStateToPlayer(state, causeType, expectedPositionMs) + } + return + } + val queue = when { + state.queue.isNotEmpty() -> state.queue + .mergeCurrentTrack(state.currentIndex, state.track) + .map { it.toSongItem() } + state.track != null -> listOf(state.track.toSongItem()) + else -> emptyList() + } + if (queue.isEmpty()) return + NPLogger.d( + TAG, + "applyRoomStateToPlayer(): roomId=${state.roomId}, version=${state.version}, queueSize=${queue.size}, currentIndex=${state.currentIndex}, playback=${state.playback.state}" + ) + + val targetIndex = state.currentIndex.coerceIn(0, queue.lastIndex) + val targetSong = queue[targetIndex] + val currentQueue = PlayerManager.currentQueueFlow.value + val currentSong = PlayerManager.currentSongFlow.value + val queueChanged = !currentQueue.sameQueueAs(queue) + val localIndex = currentQueue.indexOfFirst { it.sameTrackAs(targetSong) } + val targetIndexChanged = localIndex != targetIndex + val needsAuthoritativeStreamReload = shouldReloadForAuthoritativeStreamUrl( + targetSong = targetSong, + currentSong = currentSong + ) + + val playbackContextChanged = + queueChanged || currentSong?.sameTrackAs(targetSong) != true || needsAuthoritativeStreamReload + + if (playbackContextChanged) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + PlayerManager.playPlaylist(queue, targetIndex, commandSource = PlaybackCommandSource.REMOTE_SYNC) + } else { + if (localIndex != targetIndex) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + PlayerManager.playFromQueue(targetIndex, commandSource = PlaybackCommandSource.REMOTE_SYNC) + } + } + + val resolvedExpectedPositionMs = expectedPositionMs ?: state.playback.expectedPositionMs() + val localPositionMs = PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) + val signedDriftMs = resolvedExpectedPositionMs - localPositionMs + val driftMs = abs(signedDriftMs) + val desiredPlaying = state.playback.state == "playing" + val localPlaying = PlayerManager.isPlayingFlow.value + val shouldForcePauseAfterRemoteLoad = + !desiredPlaying && (playbackContextChanged || targetIndexChanged) + val isHeartbeatUpdate = causeType == "HEARTBEAT" + val shouldSeek = when { + playbackContextChanged || targetIndexChanged -> { + resolvedExpectedPositionMs > 0L || driftMs > TRACK_SWITCH_FORCE_SYNC_MS + } + isHeartbeatUpdate && desiredPlaying -> driftMs > HEARTBEAT_DRIFT_FORCE_SYNC_MS + desiredPlaying -> driftMs > PLAYING_DRIFT_FORCE_SYNC_MS + else -> driftMs > PAUSED_DRIFT_FORCE_SYNC_MS + } + NPLogger.d( + TAG, + "applyRoomStateToPlayer(): causeType=$causeType, desiredPlaying=$desiredPlaying, localPlaying=$localPlaying, driftMs=$driftMs, signedDriftMs=$signedDriftMs, shouldSeek=$shouldSeek, needsAuthoritativeStreamReload=$needsAuthoritativeStreamReload, shouldForcePauseAfterRemoteLoad=$shouldForcePauseAfterRemoteLoad" + ) + when { + desiredPlaying -> { + if (shouldSeek) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + PlayerManager.seekTo(resolvedExpectedPositionMs, commandSource = PlaybackCommandSource.REMOTE_SYNC) + } else { + applySoftDriftCorrection( + driftMs = driftMs, + signedDriftMs = signedDriftMs, + allowSoftSync = !queueChanged && !targetIndexChanged + ) + } + if (!localPlaying) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + PlayerManager.play(commandSource = PlaybackCommandSource.REMOTE_SYNC) + } + } + + else -> { + PlayerManager.resetListenTogetherSyncPlaybackRate() + if (shouldSeek) { + PlayerManager.seekTo(resolvedExpectedPositionMs, commandSource = PlaybackCommandSource.REMOTE_SYNC) + } + if (shouldForcePauseAfterRemoteLoad || localPlaying) { + PlayerManager.pause(forcePersist = false, commandSource = PlaybackCommandSource.REMOTE_SYNC) + } + } + } + } + + fun buildSetTrackEvent( + queue: List, + currentIndex: Int, + positionMs: Long, + shouldPlay: Boolean + ): ListenTogetherEvent { + val (shareableQueue, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = currentIndex, + roomSettings = _roomState.value?.settings, + includeResolvedStreamUrl = isCurrentUserController() + ) + return ListenTogetherEvent( + type = "SET_TRACK", + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + positionMs = positionMs.coerceAtLeast(0L), + currentIndex = resolvedCurrentIndex, + track = shareableQueue.getOrNull(resolvedCurrentIndex), + queue = shareableQueue, + shouldPlay = shouldPlay + ) + } + + fun buildPlayEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("PLAY", positionMs) + + fun buildPauseEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("PAUSE", positionMs) + + fun buildSeekEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("SEEK", positionMs) + + fun buildRequestPlayEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("REQUEST_PLAY", positionMs) + + fun buildRequestPauseEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("REQUEST_PAUSE", positionMs) + + fun buildRequestSeekEvent(positionMs: Long): ListenTogetherEvent = playbackSnapshotEvent("REQUEST_SEEK", positionMs) + + fun buildHeartbeatEvent(state: String, positionMs: Long): ListenTogetherEvent { + val queue = PlayerManager.currentQueueFlow.value + val currentSong = PlayerManager.currentSongFlow.value + val rawIndex = queue.indexOfFirst { song -> + currentSong != null && song.sameTrackAs(currentSong) + } + val (shareableQueue, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = rawIndex.takeIf { it >= 0 } ?: 0, + roomSettings = _roomState.value?.settings, + includeResolvedStreamUrl = isCurrentUserController() + ) + val shareableTrack = shareableQueue.getOrNull(resolvedCurrentIndex) + return ListenTogetherEvent( + type = "HEARTBEAT", + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + currentIndex = resolvedCurrentIndex, + track = shareableTrack, + queue = shareableQueue, + state = state, + positionMs = positionMs.coerceAtLeast(0L) + ) + } + + fun buildRequestLinkEvent( + stableKey: String, + currentIndex: Int? = null, + track: ListenTogetherTrack? = null + ): ListenTogetherEvent { + return ListenTogetherEvent( + type = "REQUEST_LINK", + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + currentIndex = currentIndex, + track = track, + requestTrackStableKey = stableKey + ) + } + + fun buildLinkReadyEvent( + stableKey: String, + positionMs: Long + ): ListenTogetherEvent? { + val queue = PlayerManager.currentQueueFlow.value + val currentSong = PlayerManager.currentSongFlow.value ?: return null + val rawIndex = queue.indexOfFirst { song -> song.sameTrackAs(currentSong) } + val (shareableQueue, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = rawIndex.takeIf { it >= 0 } ?: 0, + roomSettings = _roomState.value?.settings, + includeResolvedStreamUrl = true + ) + val shareableTrack = shareableQueue.getOrNull(resolvedCurrentIndex) ?: return null + if (shareableTrack.stableKey != stableKey) return null + val resolvedStreamUrl = normalizedDirectStreamUrl(shareableTrack.streamUrl) ?: return null + return ListenTogetherEvent( + type = "LINK_READY", + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + currentIndex = resolvedCurrentIndex, + track = shareableTrack.withStreamUrl(resolvedStreamUrl), + queue = shareableQueue, + state = if (PlayerManager.isPlayingFlow.value) "playing" else "paused", + positionMs = positionMs.coerceAtLeast(0L), + requestTrackStableKey = stableKey + ) + } + + fun buildRequestSetTrackEvent( + queue: List, + currentIndex: Int, + positionMs: Long, + shouldPlay: Boolean + ): ListenTogetherEvent { + return buildSetTrackEvent( + queue = queue, + currentIndex = currentIndex, + positionMs = positionMs, + shouldPlay = shouldPlay + ).copy(type = "REQUEST_SET_TRACK") + } + + private fun playbackSnapshotEvent(type: String, positionMs: Long): ListenTogetherEvent { + val queue = PlayerManager.currentQueueFlow.value + val currentSong = PlayerManager.currentSongFlow.value + val rawIndex = queue.indexOfFirst { song -> + currentSong != null && song.sameTrackAs(currentSong) + } + val (shareableQueue, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = rawIndex.takeIf { it >= 0 } ?: 0, + roomSettings = _roomState.value?.settings, + includeResolvedStreamUrl = isCurrentUserController() + ) + val shareableTrack = shareableQueue.getOrNull(resolvedCurrentIndex) + val resolvedState = when (type.removePrefix("REQUEST_")) { + "PLAY" -> "playing" + "PAUSE" -> "paused" + else -> if (PlayerManager.isPlayingFlow.value) "playing" else "paused" + } + return ListenTogetherEvent( + type = type, + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + positionMs = positionMs.coerceAtLeast(0L), + currentIndex = resolvedCurrentIndex, + track = shareableTrack, + queue = shareableQueue, + shouldPlay = resolvedState == "playing", + state = resolvedState + ) + } + + private fun updateSession(baseUrl: String, response: ListenTogetherRoomResponse) { + val normalizedBaseUrl = resolveListenTogetherBaseUrl(baseUrl) + val resolvedWsUrl = response.wsUrl + ?.takeUnless { wsUrl -> + wsUrl.contains("://room.internal/", ignoreCase = true) || + wsUrl.contains("://room.internal?", ignoreCase = true) || + wsUrl.contains("://room.internal:", ignoreCase = true) + } + ?: run { + val roomId = response.roomId + val token = response.token + if (!roomId.isNullOrBlank() && !token.isNullOrBlank()) { + buildListenTogetherWsUrl(normalizedBaseUrl, roomId, token) + } else { + null + } + } + NPLogger.d( + TAG, + "updateSession(): roomId=${response.roomId}, role=${response.role}, tokenPresent=${!response.token.isNullOrBlank()}, wsUrl=$resolvedWsUrl" + ) + _sessionState.value = _sessionState.value.copy( + baseUrl = normalizedBaseUrl, + roomId = response.roomId, + userUuid = response.userUuid ?: response.userId, + nickname = response.nickname, + role = resolveSessionRole( + sessionUserId = response.userUuid ?: response.userId, + fallbackRole = response.role, + state = response.state + ), + token = response.token, + wsUrl = resolvedWsUrl, + lastError = response.error, + roomNotice = null + ) + response.state?.let { + val resolvedState = resolveJoinAutoPauseState( + state = it, + autoPauseOnJoin = response.autoPauseOnJoin, + role = response.role + ) + applyRoomState(resolvedState, null) + applyRoomStateToPlayer( + resolvedState, + causeType = if (response.autoPauseOnJoin) "JOIN_AUTO_PAUSE" else null + ) + } + } + + private fun applyRoomState( + state: ListenTogetherRoomState, + expectedPositionMs: Long? + ) { + NPLogger.d( + TAG, + "applyRoomState(): roomId=${state.roomId}, version=${state.version}, members=${state.members.size}, expectedPositionMs=$expectedPositionMs" + ) + lastAppliedRoomVersion = maxOf(lastAppliedRoomVersion, state.version) + _roomState.value = state + _sessionState.value = _sessionState.value.copy( + roomId = state.roomId, + role = resolveSessionRole( + sessionUserId = _sessionState.value.userUuid, + fallbackRole = _sessionState.value.role, + state = state + ), + expectedPositionMs = expectedPositionMs, + roomNotice = roomNoticeForState(state) + ) + maybeRecoverMissingListenerMembership(state, reason = "apply_room_state") + } + + private fun handleSocketRoomState(message: ListenTogetherSocketEnvelope) { + val state = message.state ?: return + val resolvedState = resolveJoinAutoPauseState( + state = state, + autoPauseOnJoin = message.autoPauseOnJoin, + role = message.role ?: _sessionState.value.role + ) + if (shouldIgnoreStaleRoomState(resolvedState, message.causedBy)) { + NPLogger.d( + TAG, + "handleSocketRoomState(): stale version=${resolvedState.version}, lastApplied=$lastAppliedRoomVersion" + ) + return + } + if (shouldIgnoreIncomingState(message.causedBy)) { + NPLogger.d( + TAG, + "handleSocketRoomState(): ignored causedBy=${message.causedBy?.type}:${message.causedBy?.eventId}" + ) + return + } + markInboundEvent(message.causedBy?.eventId) + applyRoomState(resolvedState, message.expectedPositionMs) + val currentUserUuid = _sessionState.value.userUuid + if (isCurrentUserController() && message.causedBy?.userUuid == currentUserUuid) { + maybePublishControllerRecoveryHeartbeat(message) + return + } + applyRoomStateToPlayer( + resolvedState, + message.causedBy?.type ?: if (message.autoPauseOnJoin) "JOIN_AUTO_PAUSE" else null, + message.expectedPositionMs + ) + maybeRequestControllerLink(resolvedState, message.causedBy?.type) + maybePublishControllerRecoveryHeartbeat(message) + } + + private fun handleLinkRequested(message: ListenTogetherSocketEnvelope) { + val snapshot = _sessionState.value + if (!isCurrentUserController(snapshot)) return + val stableKey = message.requestTrackStableKey + ?: message.track?.stableKey + ?: return + publishControllerLinkReadyIfPossible(stableKey = stableKey, reason = "request:${message.causedBy?.userUuid}") + } + + private fun handleMemberControlRequested(message: ListenTogetherSocketEnvelope) { + val snapshot = _sessionState.value + if (!isCurrentUserController(snapshot)) return + val requestSequence = message.requestSequence ?: 0L + if (requestSequence > 0L && requestSequence <= lastHandledForwardedRequestSequence) { + NPLogger.d( + TAG, + "handleMemberControlRequested(): ignore duplicate/outdated requestSequence=$requestSequence, lastHandled=$lastHandledForwardedRequestSequence" + ) + return + } + val forwardedEvent = buildControllerCommitEventFromForwardedRequest(message) ?: run { + NPLogger.w( + TAG, + "handleMemberControlRequested(): invalid forwarded request type=${message.causedBy?.type}, requester=${message.causedBy?.userUuid}" + ) + return + } + requestSequence.takeIf { it > 0L }?.let { lastHandledForwardedRequestSequence = it } + if ( + SystemClock.elapsedRealtime() - lastControllerLocalControlAtElapsedMs < + CONTROLLER_LOCAL_CONTROL_COOLDOWN_MS + ) { + NPLogger.d( + TAG, + "handleMemberControlRequested(): controller local action wins, skip requestSequence=$requestSequence, requester=${message.causedBy?.userUuid}" + ) + publishControllerHeartbeatIfNeeded(force = true, reason = "controller_priority") + return + } + NPLogger.d( + TAG, + "handleMemberControlRequested(): requestSequence=$requestSequence, requester=${message.causedBy?.userUuid}, type=${message.causedBy?.type}, commitType=${forwardedEvent.type}" + ) + applyForwardedControllerRequestLocally(message, forwardedEvent) + markOutboundEvent(forwardedEvent.eventId) + noteOutboundSync() + if (!sendControlEventPureWebSocket(forwardedEvent, "forwarded_member_control")) { + NPLogger.w( + TAG, + "handleMemberControlRequested(): websocket unavailable, requester=${message.causedBy?.userUuid}, requestSequence=$requestSequence" + ) + } + } + + private fun handleRoomSuspended(message: ListenTogetherSocketEnvelope) { + val state = message.state ?: return + if (shouldIgnoreStaleRoomState(state, message.causedBy)) return + NPLogger.w( + TAG, + "handleRoomSuspended(): roomId=${state.roomId}, controllerOfflineSince=${state.controllerOfflineSince}" + ) + applyRoomState(state, message.expectedPositionMs) + _sessionState.value = _sessionState.value.copy( + roomNotice = roomNoticeForState(state, message.message) + ) + } + + private fun handleRoomResumed(message: ListenTogetherSocketEnvelope) { + val state = message.state ?: return + if (shouldIgnoreStaleRoomState(state, message.causedBy)) return + NPLogger.d(TAG, "handleRoomResumed(): roomId=${state.roomId}, version=${state.version}") + applyRoomState(state, message.expectedPositionMs) + val currentUserUuid = _sessionState.value.userUuid + if (!isCurrentUserController() || message.causedBy?.userUuid != currentUserUuid) { + applyRoomStateToPlayer(state, message.message, message.expectedPositionMs) + } + _sessionState.value = _sessionState.value.copy( + roomNotice = roomNoticeForState(state, message.message), + lastError = null + ) + } + + private fun handleRoomClosed(message: ListenTogetherSocketEnvelope) { + val state = message.state + NPLogger.w( + TAG, + "handleRoomClosed(): roomId=${message.roomId ?: state?.roomId}, message=${message.message}" + ) + state?.let { _roomState.value = it } + closeRoomLocally(message.message ?: roomNoticeForState(state)) + } + + private suspend fun handleLocalPlaybackCommand(command: PlaybackCommand) { + val snapshot = _sessionState.value + NPLogger.d( + TAG, + "handleLocalPlaybackCommand(): type=${command.type}, source=${command.source}, connection=${snapshot.connectionState}, role=${currentRole(snapshot)}, roomId=${snapshot.roomId}" + ) + if (command.source != PlaybackCommandSource.LOCAL) return + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) return + if (snapshot.roomId.isNullOrBlank()) return + resolveControlBlockReason(snapshot, _roomState.value, command)?.let { reason -> + NPLogger.w(TAG, "handleLocalPlaybackCommand(): blocked, reason=$reason") + _sessionState.value = _sessionState.value.copy(lastError = reason) + return + } + + val event = buildEventForPlaybackCommand(command) ?: return + noteControllerLocalControl(command) + markOutboundEvent(event.eventId) + noteOutboundSync() + NPLogger.d( + TAG, + "sendEvent(): type=${event.type}, eventId=${event.eventId}, currentIndex=${event.currentIndex}, positionMs=${event.positionMs}, queueSize=${event.queue?.size}" + ) + val wsSent = sendControlEventPureWebSocket(event, "local_playback_command") + NPLogger.d(TAG, "sendEvent(): websocketSent=$wsSent, type=${event.type}, eventId=${event.eventId}") + if (!wsSent) { + NPLogger.w(TAG, "sendEvent(): websocket unavailable, type=${event.type}, eventId=${event.eventId}") + } + } + + private fun buildEventForPlaybackCommand(command: PlaybackCommand): ListenTogetherEvent? { + val queue = PlayerManager.currentQueueFlow.value + val currentSong = PlayerManager.currentSongFlow.value + val currentIndex = command.currentIndex + ?: queue.indexOfFirst { song -> + currentSong != null && song.sameTrackAs(currentSong) + }.takeIf { it >= 0 } + ?: 0 + val positionMs = command.positionMs ?: PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) + val shouldPlay = PlayerManager.isPlayingFlow.value + val roomSettings = _roomState.value?.settings + + return when (command.type) { + "PLAY_PLAYLIST", + "PLAY_FROM_QUEUE", + "NEXT", + "PREVIOUS" -> if (isCurrentUserController()) { + buildSetTrackEvent( + queue = queue, + currentIndex = currentIndex, + positionMs = positionMs, + shouldPlay = shouldPlay + ) + } else { + buildRequestSetTrackEvent( + queue = queue, + currentIndex = currentIndex, + positionMs = positionMs, + shouldPlay = shouldPlay + ) + } + + "PLAY" -> if (isCurrentUserController()) buildPlayEvent(positionMs) else buildRequestPlayEvent(positionMs) + "PAUSE" -> if (isCurrentUserController()) buildPauseEvent(positionMs) else buildRequestPauseEvent(positionMs) + "SEEK" -> { + val (shareableQueue, resolvedCurrentIndex) = queue.toShareableQueueSnapshot( + currentIndex = currentIndex, + roomSettings = roomSettings, + includeResolvedStreamUrl = isCurrentUserController() + ) + val shareableTrack = shareableQueue.getOrNull(resolvedCurrentIndex) + val event = if (isCurrentUserController()) buildSeekEvent(positionMs) else buildRequestSeekEvent(positionMs) + event.copy( + currentIndex = resolvedCurrentIndex, + track = shareableTrack + ) + } + else -> null + } + } + + private fun buildControllerCommitEventFromForwardedRequest( + message: ListenTogetherSocketEnvelope + ): ListenTogetherEvent? { + val requestType = message.causedBy?.type ?: return null + val commitType = requestType.removePrefix("REQUEST_") + if (commitType == requestType) return null + val queue = message.queue + val currentIndex = message.currentIndex + val track = message.track + val positionMs = message.positionMs ?: message.expectedPositionMs ?: 0L + return ListenTogetherEvent( + type = commitType, + eventId = nextEventId(), + clientTimeMs = System.currentTimeMillis(), + positionMs = positionMs.coerceAtLeast(0L), + currentIndex = currentIndex, + track = track, + queue = queue, + shouldPlay = message.shouldPlay, + state = message.stateName, + requestTrackStableKey = message.requestTrackStableKey + ) + } + + private fun applyForwardedControllerRequestLocally( + message: ListenTogetherSocketEnvelope, + committedEvent: ListenTogetherEvent + ) { + val currentState = _roomState.value ?: return + val nextQueue = message.queue + ?.mergeCurrentTrack(message.currentIndex ?: currentState.currentIndex, message.track) + ?: currentState.queue.mergeCurrentTrack(currentState.currentIndex, currentState.track) + val nextIndex = (message.currentIndex ?: currentState.currentIndex).coerceIn( + 0, + (nextQueue.lastIndex).coerceAtLeast(0) + ) + val nextTrack = message.track ?: nextQueue.getOrNull(nextIndex) ?: currentState.track + val nextPlaybackState = when (committedEvent.type) { + "PLAY" -> "playing" + "PAUSE" -> "paused" + else -> message.stateName ?: if (message.shouldPlay == true) "playing" else currentState.playback.state + } + val syntheticState = currentState.copy( + queue = nextQueue, + currentIndex = nextIndex, + track = nextTrack, + playback = currentState.playback.copy( + state = nextPlaybackState, + basePositionMs = (committedEvent.positionMs ?: message.expectedPositionMs ?: 0L).coerceAtLeast(0L), + baseTimestampMs = System.currentTimeMillis() + ) + ) + applyRoomState(syntheticState, committedEvent.positionMs ?: message.expectedPositionMs) + applyRoomStateToPlayer( + syntheticState, + message.causedBy?.type ?: committedEvent.type, + committedEvent.positionMs ?: message.expectedPositionMs + ) + } + + private fun resolveControlBlockReason( + sessionState: ListenTogetherSessionState, + roomState: ListenTogetherRoomState?, + command: PlaybackCommand + ): String? { + if ( + roomState?.roomStatus == ListenTogetherRoomStatuses.CONTROLLER_OFFLINE && + currentRole(sessionState) != "controller" + ) { + return if (roomState.settings.normalized().shareAudioLinks) "房主已离线,无法获取播放链接" else "controller offline" + } + if ( + currentRole(sessionState) == "listener" && + roomState?.settings.normalized()?.allowMemberControl == false && + command.type in CONTROLLED_PLAYBACK_COMMAND_TYPES + ) { + return "当前房间未开启共同控制" + } + return null + } + + private fun shouldIgnoreIncomingState(cause: ListenTogetherCause?): Boolean { + val eventId = cause?.eventId + if (cause?.type?.startsWith("REQUEST_") == true) return false + val currentUserId = _sessionState.value.userUuid + if (!eventId.isNullOrBlank() && hasRecentOutboundEvent(eventId)) return true + if (!eventId.isNullOrBlank() && hasRecentInboundEvent(eventId)) return true + if (!eventId.isNullOrBlank() && cause?.userUuid == currentUserId) return true + return false + } + + private fun shouldIgnoreStaleRoomState( + state: ListenTogetherRoomState, + cause: ListenTogetherCause? + ): Boolean { + if (state.version <= lastAppliedRoomVersion) return true + val currentUserId = _sessionState.value.userUuid + if ( + currentUserId == (state.controllerUserUuid ?: state.controllerUserId) && + cause?.userUuid == currentUserId && + SystemClock.elapsedRealtime() - lastControllerLocalControlAtElapsedMs < CONTROLLER_LOCAL_CONTROL_COOLDOWN_MS && + state.version <= lastAppliedRoomVersion + 1 + ) { + return true + } + return false + } + + private fun startHeartbeat() { + if (heartbeatJob?.isActive == true) return + NPLogger.d(TAG, "startHeartbeat()") + if (lastOutboundSyncAtMs == 0L) { + lastOutboundSyncAtMs = SystemClock.elapsedRealtime() + } + heartbeatJob = scope.launch { + while (isActive) { + delay(HEARTBEAT_POLL_INTERVAL_MS) + val snapshot = _sessionState.value + if ( + snapshot.connectionState != ListenTogetherConnectionState.CONNECTED || + !isCurrentUserController(snapshot) + ) { + continue + } + val now = SystemClock.elapsedRealtime() + val idleMs = now - lastOutboundSyncAtMs + if (idleMs < HEARTBEAT_IDLE_THRESHOLD_MS) { + continue + } + val heartbeat = buildHeartbeatEvent( + state = if (PlayerManager.isPlayingFlow.value) "playing" else "paused", + positionMs = PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) + ) + markOutboundEvent(heartbeat.eventId) + noteOutboundSync() + NPLogger.d( + TAG, + "heartbeat(): eventId=${heartbeat.eventId}, positionMs=${heartbeat.positionMs}, idleMs=$idleMs" + ) + sendControlEventPureWebSocket(heartbeat, "heartbeat") + } + } + } + + private fun stopHeartbeat() { + NPLogger.d(TAG, "stopHeartbeat()") + heartbeatJob?.cancel() + heartbeatJob = null + } + + private suspend fun handleResolvedStreamUrlChanged(url: String?) { + val streamUrl = url?.trim().orEmpty() + if (streamUrl.isBlank()) return + if (!streamUrl.startsWith("https://", ignoreCase = true) && !streamUrl.startsWith("http://", ignoreCase = true)) { + return + } + val snapshot = _sessionState.value + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) return + if (snapshot.roomId.isNullOrBlank()) return + if (!isCurrentUserController(snapshot)) return + if (!_roomState.value?.settings.normalized().shareAudioLinks) return + val currentStableKey = PlayerManager.currentSongFlow.value?.toListenTogetherTrackOrNull()?.stableKey + if (!currentStableKey.isNullOrBlank()) { + publishControllerLinkReadyIfPossible( + stableKey = currentStableKey, + reason = "stream_url_resolved" + ) + } else { + publishControllerHeartbeatIfNeeded(force = true, reason = "stream_url_resolved") + } + } + + private fun scheduleReconnect(reason: String) { + val snapshot = _sessionState.value + if (!reconnectEnabled) { + NPLogger.d(TAG, "scheduleReconnect(): skipped, reconnect disabled, reason=$reason") + return + } + if (snapshot.wsUrl.isNullOrBlank() || snapshot.roomId.isNullOrBlank()) { + NPLogger.d(TAG, "scheduleReconnect(): skipped, missing room/wsUrl, reason=$reason") + return + } + if (snapshot.connectionState == ListenTogetherConnectionState.CONNECTING) { + NPLogger.d(TAG, "scheduleReconnect(): skipped, already connecting, reason=$reason") + return + } + if (reconnectJob?.isActive == true) { + NPLogger.d(TAG, "scheduleReconnect(): already scheduled, reason=$reason") + return + } + val attempt = reconnectAttempt + 1 + val delayMs = reconnectDelayMs(attempt) + reconnectAttempt = attempt + NPLogger.w( + TAG, + "scheduleReconnect(): roomId=${snapshot.roomId}, attempt=$attempt, delayMs=$delayMs, reason=$reason" + ) + reconnectJob = scope.launch { + delay(delayMs) + reconnectJob = null + val latest = _sessionState.value + if (!reconnectEnabled || latest.wsUrl.isNullOrBlank() || latest.roomId.isNullOrBlank()) { + NPLogger.d(TAG, "scheduleReconnect(): cancelled before execution") + return@launch + } + NPLogger.d(TAG, "reconnect(): roomId=${latest.roomId}, attempt=$attempt") + if (tryRecoverMembershipBeforeReconnect("scheduled_reconnect:$reason")) { + return@launch + } + connectWebSocket() + } + } + + private fun sendControlEventPureWebSocket( + event: ListenTogetherEvent, + reason: String + ): Boolean { + val snapshot = _sessionState.value + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) { + handleWebSocketControlSendFailure( + event = event, + reason = "$reason:not_connected" + ) + return false + } + val sent = sendControlEventOverWebSocket(event) + if (!sent) { + handleWebSocketControlSendFailure( + event = event, + reason = "$reason:send_failed" + ) + } + return sent + } + + private fun handleWebSocketControlSendFailure( + event: ListenTogetherEvent, + reason: String + ) { + pendingStateRefreshAfterReconnect = true + val resolvedMessage = "一起听连接不可用,正在重连" + NPLogger.w( + TAG, + "handleWebSocketControlSendFailure(): type=${event.type}, eventId=${event.eventId}, reason=$reason" + ) + _sessionState.value = _sessionState.value.copy(lastError = resolvedMessage) + scheduleReconnect("control_send_failed:${event.type}:$reason") + } + + private suspend fun refreshRoomStateAfterReconnect(reason: String) { + val snapshot = _sessionState.value + val baseUrl = snapshot.baseUrl + val roomId = snapshot.roomId + if (baseUrl.isNullOrBlank() || roomId.isNullOrBlank()) return + runCatching { + refreshRoomState(baseUrl, roomId) + }.onFailure { error -> + NPLogger.w( + TAG, + "refreshRoomStateAfterReconnect(): failed, reason=$reason, error=${error.message}" + ) + val resolvedError = error.message ?: error.javaClass.simpleName + _sessionState.value = _sessionState.value.copy(lastError = resolvedError) + if (handleTerminalReconnectFailure(resolvedError, "refresh_after_reconnect")) { + return@onFailure + } + if (!maybeRecoverFromFatalMembershipError(resolvedError, "refresh_after_reconnect")) { + scheduleReconnect("refresh_state_failed:$reason") + } + } + } + + private fun maybeRecoverMissingListenerMembership( + state: ListenTogetherRoomState, + reason: String + ) { + val snapshot = _sessionState.value + val userUuid = snapshot.userUuid ?: return + if (isCurrentUserController(snapshot)) return + if (state.roomStatus == ListenTogetherRoomStatuses.CLOSED) return + if (state.members.any { it.userUuid.ifBlank { it.userId.orEmpty() } == userUuid }) return + NPLogger.w( + TAG, + "maybeRecoverMissingListenerMembership(): userUuid=$userUuid missing from roomId=${state.roomId}, reason=$reason" + ) + triggerListenerMembershipRecovery("$reason:missing_member") + } + + private fun maybeRecoverFromFatalMembershipError( + errorMessage: String?, + reason: String + ): Boolean { + val normalized = errorMessage?.trim()?.lowercase().orEmpty() + if ( + "member not in room" !in normalized && + "member missing" !in normalized + ) { + return false + } + NPLogger.w(TAG, "maybeRecoverFromFatalMembershipError(): reason=$reason, error=$errorMessage") + return triggerListenerMembershipRecovery("$reason:$normalized") + } + + private fun tryRecoverMembershipBeforeReconnect(reason: String): Boolean { + val snapshot = _sessionState.value + if (isCurrentUserController(snapshot)) return false + return triggerListenerMembershipRecovery(reason) + } + + private fun triggerListenerMembershipRecovery(reason: String): Boolean { + val snapshot = _sessionState.value + val baseUrl = snapshot.baseUrl + val roomId = snapshot.roomId + val userUuid = snapshot.userUuid + val nickname = snapshot.nickname + if (baseUrl.isNullOrBlank() || roomId.isNullOrBlank() || userUuid.isNullOrBlank() || nickname.isNullOrBlank()) { + NPLogger.d(TAG, "triggerListenerMembershipRecovery(): skipped, missing session, reason=$reason") + return false + } + if (isCurrentUserController(snapshot)) { + NPLogger.d(TAG, "triggerListenerMembershipRecovery(): skipped, current user is controller") + return false + } + if (membershipRecoveryJob?.isActive == true) { + NPLogger.d(TAG, "triggerListenerMembershipRecovery(): already running, reason=$reason") + return true + } + reconnectEnabled = true + reconnectJob?.cancel() + reconnectJob = null + pendingStateRefreshAfterReconnect = true + stopHeartbeat() + webSocketClient.disconnect(code = 1000, reason = "listener_recovering") + _sessionState.value = snapshot.copy( + connectionState = ListenTogetherConnectionState.CONNECTING, + lastError = "一起听连接已失效,正在重新加入房间" + ) + membershipRecoveryJob = scope.launch { + try { + NPLogger.w( + TAG, + "triggerListenerMembershipRecovery(): rejoin roomId=$roomId, userUuid=$userUuid, reason=$reason" + ) + joinRoom(baseUrl, roomId, userUuid, nickname) + connectWebSocket() + } catch (error: Throwable) { + val resolvedError = error.message ?: error.javaClass.simpleName + NPLogger.e( + TAG, + "triggerListenerMembershipRecovery(): failed, roomId=$roomId, userUuid=$userUuid, reason=$reason, error=$resolvedError", + error + ) + _sessionState.value = _sessionState.value.copy( + connectionState = ListenTogetherConnectionState.DISCONNECTED, + lastError = resolvedError + ) + if (handleTerminalReconnectFailure(resolvedError, "listener_membership_recovery_failed")) { + return@launch + } + scheduleReconnect("listener_membership_recovery_failed:$reason") + } finally { + membershipRecoveryJob = null + } + } + return true + } + + private fun handleTerminalReconnectFailure( + errorMessage: String?, + reason: String + ): Boolean { + if (!isTerminalReconnectError(errorMessage)) { + return false + } + NPLogger.w( + TAG, + "handleTerminalReconnectFailure(): stop reconnect, reason=$reason, error=$errorMessage" + ) + closeRoomLocally(errorMessage ?: "listen_together_unavailable") + return true + } + + private fun isTerminalReconnectError(errorMessage: String?): Boolean { + val normalized = errorMessage?.trim()?.lowercase().orEmpty() + if (normalized.isBlank()) return false + return TERMINAL_RECONNECT_ERROR_MARKERS.any(normalized::contains) + } + + private fun noteOutboundSync() { + lastOutboundSyncAtMs = SystemClock.elapsedRealtime() + } + + private fun currentRole( + sessionState: ListenTogetherSessionState = _sessionState.value + ): String? { + return resolveSessionRole( + sessionUserId = sessionState.userUuid, + fallbackRole = sessionState.role, + state = _roomState.value + ) + } + + private fun isCurrentUserController( + sessionState: ListenTogetherSessionState = _sessionState.value + ): Boolean = currentRole(sessionState) == "controller" + + private fun resolveSessionRole( + sessionUserId: String?, + fallbackRole: String?, + state: ListenTogetherRoomState? + ): String? { + val normalizedUserId = sessionUserId?.trim()?.takeIf { it.isNotBlank() } + val controllerUserId = state?.controllerUserUuid?.trim()?.takeIf { it.isNotBlank() } + ?: state?.controllerUserId?.trim()?.takeIf { it.isNotBlank() } + return when { + normalizedUserId != null && controllerUserId != null -> { + if (normalizedUserId == controllerUserId) "controller" else "listener" + } + + else -> fallbackRole + } + } + + private fun hasRecentOutboundEvent(eventId: String): Boolean = synchronized(recentEventLock) { + recentOutboundEventIds.contains(eventId) + } + + private fun hasRecentInboundEvent(eventId: String): Boolean = synchronized(recentEventLock) { + recentInboundEventIds.contains(eventId) + } + + private fun markOutboundEvent(eventId: String?) { + if (eventId.isNullOrBlank()) return + synchronized(recentEventLock) { + recentOutboundEventIds.add(eventId) + trimRecentEvents(recentOutboundEventIds) + } + } + + private fun markInboundEvent(eventId: String?) { + if (eventId.isNullOrBlank()) return + synchronized(recentEventLock) { + recentInboundEventIds.add(eventId) + trimRecentEvents(recentInboundEventIds) + } + } + + private fun trimRecentEvents(events: LinkedHashSet) { + while (events.size > MAX_RECENT_EVENT_IDS) { + val oldest = events.firstOrNull() ?: break + events.remove(oldest) + } + } + + private fun closeRoomLocally(reason: String?) { + reconnectEnabled = false + reconnectAttempt = 0 + reconnectJob?.cancel() + reconnectJob = null + stopHeartbeat() + lastOutboundSyncAtMs = 0L + lastRequestedLinkStableKey = null + lastRequestedLinkAtElapsedMs = 0L + lastAppliedRoomVersion = -1L + lastControllerLocalControlAtElapsedMs = 0L + lastHandledForwardedRequestSequence = 0L + PlayerManager.resetListenTogetherSyncPlaybackRate() + webSocketClient.disconnect(code = 1000, reason = "room_closed") + val snapshot = _sessionState.value + _roomState.value = null + _sessionState.value = ListenTogetherSessionState( + baseUrl = snapshot.baseUrl, + userUuid = snapshot.userUuid, + nickname = snapshot.nickname, + connectionState = ListenTogetherConnectionState.DISCONNECTED, + lastError = reason, + roomNotice = reason + ) + } + + private fun roomNoticeForState( + state: ListenTogetherRoomState?, + fallbackMessage: String? = null + ): String? { + state ?: return fallbackMessage + return when (state.roomStatus) { + ListenTogetherRoomStatuses.CONTROLLER_OFFLINE -> { + val offlineSince = state.controllerOfflineSince ?: return fallbackMessage ?: "controller_offline" + val timeoutAt = offlineSince + CONTROLLER_GRACE_PERIOD_MS + val remainingMs = (timeoutAt - System.currentTimeMillis()).coerceAtLeast(0L) + val remainingMinutes = TimeUnit.MILLISECONDS.toMinutes(remainingMs).coerceAtLeast(0L) + "controller_offline:${remainingMinutes + 1}" + } + + ListenTogetherRoomStatuses.CLOSED -> fallbackMessage ?: state.closedReason ?: "room_closed" + else -> fallbackMessage + } + } + + private fun nextEventId(): String = "evt-${System.currentTimeMillis()}-${UUID.randomUUID()}" + + private fun noteControllerLocalControl(command: PlaybackCommand) { + if (command.source != PlaybackCommandSource.LOCAL) return + if (!isCurrentUserController()) return + if (command.type !in CONTROLLED_PLAYBACK_COMMAND_TYPES) return + lastControllerLocalControlAtElapsedMs = SystemClock.elapsedRealtime() + } + + private fun applySoftDriftCorrection( + driftMs: Long, + signedDriftMs: Long, + allowSoftSync: Boolean + ) { + if (!allowSoftSync || isCurrentUserController()) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + return + } + if (driftMs < SOFT_SYNC_MIN_DRIFT_MS || driftMs >= PLAYING_DRIFT_FORCE_SYNC_MS) { + PlayerManager.resetListenTogetherSyncPlaybackRate() + return + } + val rate = when { + signedDriftMs >= SOFT_SYNC_FAST_DRIFT_MS -> 1.05f + signedDriftMs > 0L -> 1.03f + signedDriftMs <= -SOFT_SYNC_FAST_DRIFT_MS -> 0.95f + else -> 0.97f + } + PlayerManager.setListenTogetherSyncPlaybackRate(rate) + } + + private fun publishControllerHeartbeatIfNeeded( + force: Boolean = false, + reason: String + ) { + val snapshot = _sessionState.value + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) return + if (!isCurrentUserController(snapshot)) return + val state = _roomState.value ?: return + if (state.roomStatus == ListenTogetherRoomStatuses.CLOSED) return + val heartbeat = buildHeartbeatEvent( + state = if (PlayerManager.isPlayingFlow.value) "playing" else "paused", + positionMs = PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) + ) + if (!force && heartbeat.track == null) return + markOutboundEvent(heartbeat.eventId) + noteOutboundSync() + NPLogger.d( + TAG, + "publishControllerHeartbeatIfNeeded(): reason=$reason, eventId=${heartbeat.eventId}, track=${heartbeat.track?.stableKey}, positionMs=${heartbeat.positionMs}" + ) + sendControlEventPureWebSocket(heartbeat, "publish_controller_heartbeat:$reason") + } + + private fun publishControllerLinkReadyIfPossible( + stableKey: String, + reason: String + ) { + val snapshot = _sessionState.value + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) return + if (!isCurrentUserController(snapshot)) return + if (!_roomState.value?.settings.normalized().shareAudioLinks) return + val event = buildLinkReadyEvent( + stableKey = stableKey, + positionMs = PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) + ) ?: return + markOutboundEvent(event.eventId) + noteOutboundSync() + NPLogger.d( + TAG, + "publishControllerLinkReadyIfPossible(): reason=$reason, eventId=${event.eventId}, stableKey=$stableKey" + ) + sendControlEventPureWebSocket(event, "publish_link_ready:$reason") + } + + private fun maybeRequestControllerLink( + state: ListenTogetherRoomState, + causeType: String? + ) { + val snapshot = _sessionState.value + if (snapshot.connectionState != ListenTogetherConnectionState.CONNECTED) return + if (isCurrentUserController(snapshot)) return + if (!state.settings.normalized().shareAudioLinks) return + if (state.roomStatus != ListenTogetherRoomStatuses.ACTIVE) return + val targetTrack = state.track ?: state.queue.getOrNull(state.currentIndex) ?: return + if (normalizedDirectStreamUrl(targetTrack.streamUrl) != null) return + val stableKey = targetTrack.stableKey + if (stableKey.isBlank()) return + val nowElapsedMs = SystemClock.elapsedRealtime() + if ( + lastRequestedLinkStableKey == stableKey && + nowElapsedMs - lastRequestedLinkAtElapsedMs < LINK_REQUEST_THROTTLE_MS + ) { + return + } + val event = buildRequestLinkEvent( + stableKey = stableKey, + currentIndex = state.currentIndex, + track = targetTrack.withStreamUrl(null) + ) + lastRequestedLinkStableKey = stableKey + lastRequestedLinkAtElapsedMs = nowElapsedMs + markOutboundEvent(event.eventId) + NPLogger.d( + TAG, + "maybeRequestControllerLink(): causeType=$causeType, eventId=${event.eventId}, stableKey=$stableKey" + ) + sendControlEventPureWebSocket(event, "request_controller_link:$causeType") + } + + private fun maybePublishControllerRecoveryHeartbeat(message: ListenTogetherSocketEnvelope) { + val snapshot = _sessionState.value + if (!isCurrentUserController(snapshot)) return + val state = message.state ?: return + if (!state.settings.normalized().shareAudioLinks) return + val cause = message.causedBy ?: return + if (cause.userUuid == snapshot.userUuid) return + if (cause.type == "REQUEST_LINK") { + val stableKey = message.requestTrackStableKey + ?: message.track?.stableKey + ?: state.track?.stableKey + ?: return + publishControllerLinkReadyIfPossible(stableKey = stableKey, reason = "recovery:REQUEST_LINK") + return + } + if (cause.type !in CONTROLLER_HEARTBEAT_RECOVERY_TYPES) return + publishControllerHeartbeatIfNeeded(force = true, reason = "recovery:${cause.type}") + } + + private fun shouldReloadForAuthoritativeStreamUrl( + targetSong: SongItem, + currentSong: SongItem? + ): Boolean { + if (isCurrentUserController()) return false + if (!_roomState.value?.settings.normalized().shareAudioLinks) return false + if (currentSong?.sameTrackAs(targetSong) != true) return false + val remoteStreamUrl = normalizedDirectStreamUrl(targetSong.streamUrl) ?: return false + val localTrackStreamUrl = normalizedDirectStreamUrl(currentSong.streamUrl) + val localResolvedStreamUrl = normalizedDirectStreamUrl(PlayerManager.currentMediaUrlFlow.value) + return remoteStreamUrl != localTrackStreamUrl && remoteStreamUrl != localResolvedStreamUrl + } + + companion object { + private const val TAG = "NERI-ListenTogether" + private const val PLAYING_DRIFT_FORCE_SYNC_MS = 2_500L + private const val HEARTBEAT_DRIFT_FORCE_SYNC_MS = 5_000L + private const val PAUSED_DRIFT_FORCE_SYNC_MS = 800L + private const val TRACK_SWITCH_FORCE_SYNC_MS = 500L + private const val MAX_RECENT_EVENT_IDS = 128 + private const val CONTROLLER_GRACE_PERIOD_MS = 10 * 60 * 1000L + private const val HEARTBEAT_POLL_INTERVAL_MS = 2_000L + private const val HEARTBEAT_IDLE_THRESHOLD_MS = 12_000L + private const val LINK_REQUEST_THROTTLE_MS = 4_000L + private const val CONTROLLER_LOCAL_CONTROL_COOLDOWN_MS = 1_200L + private const val SOFT_SYNC_MIN_DRIFT_MS = 600L + private const val SOFT_SYNC_FAST_DRIFT_MS = 1_500L + private val CONTROLLER_HEARTBEAT_RECOVERY_TYPES = setOf( + "MEMBER_JOINED", + "PLAY", + "PAUSE", + "SEEK", + "SET_TRACK", + "SET_QUEUE", + "REQUEST_PLAY", + "REQUEST_PAUSE", + "REQUEST_SEEK", + "REQUEST_SET_TRACK" + ) + private val CONTROLLED_PLAYBACK_COMMAND_TYPES = setOf( + "PLAY_PLAYLIST", + "PLAY_FROM_QUEUE", + "NEXT", + "PREVIOUS", + "PLAY", + "PAUSE", + "SEEK" + ) + private val TERMINAL_RECONNECT_ERROR_MARKERS = setOf( + "401 unauthorized", + "http=401", + "(401)", + "unauthorized", + "room closed", + "room not initialized", + "not found in do", + "http=404", + "(404)", + "http=410", + "(410)" + ) + + private fun reconnectDelayMs(attempt: Int): Long { + return when (attempt) { + 1 -> 1_500L + 2 -> 3_000L + 3 -> 5_000L + 4 -> 8_000L + else -> 12_000L + } + } + } +} + +private fun ListenTogetherPlaybackState.expectedPositionMs(nowMs: Long = System.currentTimeMillis()): Long { + return if (state == "playing") { + (basePositionMs + ((nowMs - baseTimestampMs) * playbackRate)).toLong().coerceAtLeast(0L) + } else { + basePositionMs.coerceAtLeast(0L) + } +} + +private fun resolveJoinAutoPauseState( + state: ListenTogetherRoomState, + autoPauseOnJoin: Boolean, + role: String? +): ListenTogetherRoomState { + if (!autoPauseOnJoin || role == "controller") return state + if (state.playback.state == "paused") return state + return state.copy( + playback = state.playback.copy( + state = "paused", + basePositionMs = state.playback.expectedPositionMs(), + baseTimestampMs = System.currentTimeMillis() + ) + ) +} + +private fun SongItem.sameTrackAs(other: SongItem): Boolean { + return resolvedChannelId() == other.resolvedChannelId() && + resolvedAudioId() == other.resolvedAudioId() && + resolvedSubAudioId() == other.resolvedSubAudioId() && + resolvedPlaylistContextId() == other.resolvedPlaylistContextId() +} + +private fun List.sameQueueAs(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> this[index].sameTrackAs(other[index]) } +} + +private fun List.toShareableQueueSnapshot( + currentIndex: Int, + roomSettings: ListenTogetherRoomSettings? = null, + includeResolvedStreamUrl: Boolean = true +): Pair, Int> { + if (isEmpty()) return emptyList() to 0 + + val targetSong = getOrNull(currentIndex.coerceIn(0, lastIndex)) + val targetStableKey = targetSong?.toListenTogetherTrackOrNull()?.stableKey + val currentStreamUrl = currentResolvedStreamUrl().takeIf { includeResolvedStreamUrl } + val shareableQueue = mapNotNull { song -> + song.toListenTogetherTrackOrNull()?.let { track -> + if (roomSettings.normalized().shareAudioLinks && track.stableKey == targetStableKey) { + track.withStreamUrl(currentStreamUrl) + } else { + track + } + } + } + if (shareableQueue.isEmpty()) return shareableQueue to 0 + + val resolvedCurrentIndex = targetStableKey?.let { stableKey -> + shareableQueue.indexOfFirst { it.stableKey == stableKey }.takeIf { it >= 0 } + } ?: 0 + + return shareableQueue to resolvedCurrentIndex +} + +private fun ListenTogetherRoomSettings?.normalized(): ListenTogetherRoomSettings { + return this ?: ListenTogetherRoomSettings() +} + +private fun currentResolvedStreamUrl(): String? { + val candidate = PlayerManager.currentMediaUrlFlow.value?.trim().orEmpty() + if (candidate.isBlank()) return null + if (candidate.startsWith("https://", ignoreCase = true) || candidate.startsWith("http://", ignoreCase = true)) { + return candidate + } + return null +} + +private fun List.mergeCurrentTrack( + currentIndex: Int, + currentTrack: ListenTogetherTrack? +): List { + val replacement = currentTrack ?: return this + if (currentIndex !in indices) return this + if (this[currentIndex] == replacement) return this + return toMutableList().also { it[currentIndex] = replacement } +} + +private fun normalizedDirectStreamUrl(value: String?): String? { + val candidate = value?.trim().orEmpty() + if (candidate.isBlank()) return null + return if ( + candidate.startsWith("https://", ignoreCase = true) || + candidate.startsWith("http://", ignoreCase = true) + ) { + candidate + } else { + null + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherValidation.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherValidation.kt new file mode 100644 index 00000000..95ef032c --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherValidation.kt @@ -0,0 +1,113 @@ +package moe.ouom.neriplayer.listentogether + +import java.util.Locale + +private val ROOM_ID_REGEX = Regex("^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$") +private val USER_UUID_REGEX = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$") +private val NICKNAME_REGEX = Regex("^[\\p{IsHan}A-Za-z0-9]{1,24}$") + +const val LISTEN_TOGETHER_ROOM_ID_LENGTH = 6 +const val LISTEN_TOGETHER_NICKNAME_MIN_LENGTH = 1 +const val LISTEN_TOGETHER_NICKNAME_MAX_LENGTH = 24 + +fun normalizeListenTogetherRoomId(value: String): String { + return value.trim().uppercase() +} + +fun validateListenTogetherRoomId(roomId: String): String? { + val normalized = normalizeListenTogetherRoomId(roomId) + return when { + normalized.length != LISTEN_TOGETHER_ROOM_ID_LENGTH -> { + if (isChineseLocale()) { + "房间 ID 需要为 $LISTEN_TOGETHER_ROOM_ID_LENGTH 位。" + } else { + "Room ID must be $LISTEN_TOGETHER_ROOM_ID_LENGTH characters." + } + } + + !ROOM_ID_REGEX.matches(normalized) -> { + if (isChineseLocale()) { + "房间 ID 仅支持大写字母和数字。" + } else { + "Room ID only supports uppercase letters and digits." + } + } + + else -> null + } +} + +fun validateListenTogetherUserUuid(userUuid: String): String? { + val normalized = userUuid.trim() + return when { + normalized.isBlank() -> { + if (isChineseLocale()) { + "用户 UUID 不能为空。" + } else { + "User UUID is required." + } + } + + !USER_UUID_REGEX.matches(normalized) -> { + if (isChineseLocale()) { + "用户 UUID 格式无效。" + } else { + "User UUID format is invalid." + } + } + + else -> null + } +} + +fun validateListenTogetherNickname(nickname: String): String? { + val normalized = nickname.trim() + return when { + normalized.length !in LISTEN_TOGETHER_NICKNAME_MIN_LENGTH..LISTEN_TOGETHER_NICKNAME_MAX_LENGTH -> { + if (isChineseLocale()) { + "当前昵称长度需要为 $LISTEN_TOGETHER_NICKNAME_MIN_LENGTH-$LISTEN_TOGETHER_NICKNAME_MAX_LENGTH 位。" + } else { + "Nickname length must be $LISTEN_TOGETHER_NICKNAME_MIN_LENGTH-$LISTEN_TOGETHER_NICKNAME_MAX_LENGTH characters." + } + } + + !NICKNAME_REGEX.matches(normalized) -> { + if (isChineseLocale()) { + "当前昵称仅支持汉字、英文字母和数字。" + } else { + "Nickname only supports Chinese characters, letters, and digits." + } + } + + else -> null + } +} + +fun sanitizeListenTogetherNicknameOrNull(nickname: String?): String? { + val normalized = nickname?.trim().orEmpty() + if (normalized.isBlank()) return null + return normalized.takeIf { validateListenTogetherNickname(it) == null } +} + +fun requireValidListenTogetherRoomId(roomId: String): String { + val normalized = normalizeListenTogetherRoomId(roomId) + validateListenTogetherRoomId(normalized)?.let(::error) + return normalized +} + +fun requireValidListenTogetherUserUuid(userUuid: String): String { + val normalized = userUuid.trim() + validateListenTogetherUserUuid(normalized)?.let(::error) + return normalized.lowercase() +} + +fun requireValidListenTogetherNickname(nickname: String): String { + val normalized = nickname.trim() + validateListenTogetherNickname(normalized)?.let(::error) + return normalized +} + +private fun isChineseLocale(): Boolean { + return Locale.getDefault().language.startsWith("zh", ignoreCase = true) +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherWebSocketClient.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherWebSocketClient.kt new file mode 100644 index 00000000..b391439a --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherWebSocketClient.kt @@ -0,0 +1,89 @@ +package moe.ouom.neriplayer.listentogether + +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +class ListenTogetherWebSocketClient( + private val okHttpClient: OkHttpClient +) { + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + explicitNulls = false + } + + private var webSocket: WebSocket? = null + + fun connect( + wsUrl: String, + listener: Listener + ) { + disconnect() + val request = Request.Builder().url(wsUrl).build() + val activeSocket = okHttpClient.newWebSocket( + request, + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + if (this@ListenTogetherWebSocketClient.webSocket !== webSocket) return + listener.onOpen() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + if (this@ListenTogetherWebSocketClient.webSocket !== webSocket) return + runCatching { + json.decodeFromString(text) + }.onSuccess(listener::onMessage) + .onFailure { listener.onProtocolError(text, it) } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (this@ListenTogetherWebSocketClient.webSocket !== webSocket) return + listener.onClosed(code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (this@ListenTogetherWebSocketClient.webSocket !== webSocket) return + val message = buildString { + append(t.message ?: t.javaClass.simpleName) + response?.let { + append(" (http=") + append(it.code) + append(' ') + append(it.message) + append(')') + } + } + listener.onFailure(IllegalStateException(message, t)) + } + } + ) + webSocket = activeSocket + } + + fun sendEvent(event: ListenTogetherEvent): Boolean { + return webSocket?.send(json.encodeToString(event)) == true + } + + fun sendPing(): Boolean { + return webSocket?.send("""{"type":"ping"}""") == true + } + + fun disconnect(code: Int = 1000, reason: String = "client_closed") { + webSocket?.close(code, reason) + webSocket = null + } + + interface Listener { + fun onOpen() + fun onMessage(message: ListenTogetherSocketEnvelope) + fun onClosed(code: Int, reason: String) + fun onFailure(error: Throwable) + fun onProtocolError(rawText: String, error: Throwable) + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt index 9502a760..6db27ec9 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.FormatSize +import androidx.compose.material.icons.outlined.Headphones import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.LibraryMusic @@ -180,6 +181,7 @@ import moe.ouom.neriplayer.ui.component.SleepTimerDialog import moe.ouom.neriplayer.ui.component.WaveformSlider import moe.ouom.neriplayer.ui.component.parseNeteaseLrc import moe.ouom.neriplayer.ui.component.parseNeteaseYrc +import moe.ouom.neriplayer.ui.screen.debug.ListenTogetherRoomPanel import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.ui.viewmodel.tab.NeteaseAlbum @@ -1330,6 +1332,7 @@ fun MoreOptionsSheet( var showOffsetSheet by remember { mutableStateOf(false) } var showFontSizeSheet by remember { mutableStateOf(false) } var showEditInfoSheet by remember { mutableStateOf(false) } + var showListenTogetherSheet by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } @@ -1362,17 +1365,18 @@ fun MoreOptionsSheet( containerColor = MaterialTheme.colorScheme.surface ) { // 处理子页面的返回键导航 - BackHandler(enabled = showOffsetSheet || showFontSizeSheet || showSearchView || showEditInfoSheet) { + BackHandler(enabled = showOffsetSheet || showFontSizeSheet || showSearchView || showEditInfoSheet || showListenTogetherSheet) { when { showOffsetSheet -> showOffsetSheet = false showFontSizeSheet -> showFontSizeSheet = false showSearchView -> showSearchView = false showEditInfoSheet -> showEditInfoSheet = false + showListenTogetherSheet -> showListenTogetherSheet = false } } // 处理主页面的返回键 - BackHandler(enabled = !showOffsetSheet && !showFontSizeSheet && !showSearchView && !showEditInfoSheet) { + BackHandler(enabled = !showOffsetSheet && !showFontSizeSheet && !showSearchView && !showEditInfoSheet && !showListenTogetherSheet) { coroutineScope.launch { sheetState.hide() onDismiss() @@ -1385,6 +1389,7 @@ fun MoreOptionsSheet( showFontSizeSheet -> "FontSize" showSearchView -> "Search" showEditInfoSheet -> "EditInfo" + showListenTogetherSheet -> "ListenTogether" else -> "Main" }, transitionSpec = { @@ -1553,6 +1558,27 @@ fun MoreOptionsSheet( } } ) + ListItem( + headlineContent = { Text(stringResource(R.string.listen_together_title)) }, + leadingContent = { Icon(Icons.Outlined.Headphones, null) }, + modifier = Modifier.clickable { showListenTogetherSheet = true } + ) + } + } + + "ListenTogether" -> { + val listenTogetherScrollState = rememberScrollState() + Column( + Modifier + .fillMaxWidth() + .verticalScroll(listenTogetherScrollState) + .padding(horizontal = 16.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + ListenTogetherRoomPanel( + modifier = Modifier.fillMaxWidth(), + showBaseUrlEditor = false + ) } } @@ -1872,12 +1898,12 @@ fun EditSongInfoSheet( val scrollState = rememberScrollState() val nestedScrollConnection = remember { object : NestedScrollConnection { - override fun onPreScroll(_available: androidx.compose.ui.geometry.Offset, _source: NestedScrollSource): androidx.compose.ui.geometry.Offset { + override fun onPreScroll(available: androidx.compose.ui.geometry.Offset, source: NestedScrollSource): androidx.compose.ui.geometry.Offset { // 在滚动前不消费,让 verticalScroll 正常处理 return androidx.compose.ui.geometry.Offset.Zero } - override fun onPostScroll(_consumed: androidx.compose.ui.geometry.Offset, available: androidx.compose.ui.geometry.Offset, _source: NestedScrollSource): androidx.compose.ui.geometry.Offset { + override fun onPostScroll(consumed: androidx.compose.ui.geometry.Offset, available: androidx.compose.ui.geometry.Offset, source: NestedScrollSource): androidx.compose.ui.geometry.Offset { // 消费所有剩余滚动事件,防止传递给 ModalBottomSheet return available } @@ -1887,7 +1913,7 @@ fun EditSongInfoSheet( return available } - override suspend fun onPostFling(_consumed: androidx.compose.ui.unit.Velocity, available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { + override suspend fun onPostFling(consumed: androidx.compose.ui.unit.Velocity, available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { // 消费所有剩余 fling 速度 return available } @@ -2437,12 +2463,12 @@ fun LyricsEditorSheet( // 创建嵌套滚动连接来消费滚动事件,防止传递给 ModalBottomSheet val nestedScrollConnection = remember { object : NestedScrollConnection { - override fun onPreScroll(_available: androidx.compose.ui.geometry.Offset, _source: NestedScrollSource): androidx.compose.ui.geometry.Offset { + override fun onPreScroll(available: androidx.compose.ui.geometry.Offset, source: NestedScrollSource): androidx.compose.ui.geometry.Offset { // 在滚动前不消费,让内部滚动正常处理 return androidx.compose.ui.geometry.Offset.Zero } - override fun onPostScroll(_consumed: androidx.compose.ui.geometry.Offset, available: androidx.compose.ui.geometry.Offset, _source: NestedScrollSource): androidx.compose.ui.geometry.Offset { + override fun onPostScroll(consumed: androidx.compose.ui.geometry.Offset, available: androidx.compose.ui.geometry.Offset, source: NestedScrollSource): androidx.compose.ui.geometry.Offset { // 消费所有剩余滚动事件,防止传递给 ModalBottomSheet return available } @@ -2452,7 +2478,7 @@ fun LyricsEditorSheet( return available } - override suspend fun onPostFling(_consumed: androidx.compose.ui.unit.Velocity, available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { + override suspend fun onPostFling(consumed: androidx.compose.ui.unit.Velocity, available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { // 消费所有剩余 fling 速度 return available } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt index adf37adf..fd7de4af 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt @@ -1,8 +1,6 @@ package moe.ouom.neriplayer.ui.screen.debug import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -15,11 +13,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Build import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Error +import androidx.compose.material.icons.outlined.Headphones import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.Warning @@ -70,9 +71,7 @@ fun DebugHomeScreen( ) }, headlineContent = { Text(stringResource(R.string.debug_tools)) }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), supportingContent = { Text(stringResource(R.string.debug_select_platform)) }, ) @@ -82,6 +81,25 @@ fun DebugHomeScreen( ) ) { Column(Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Outlined.Headphones, + contentDescription = stringResource(R.string.listen_together_title), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + }, + headlineContent = { Text(stringResource(R.string.listen_together_title)) }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) + + ListenTogetherDebugPanel( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + ListItem( leadingContent = { Icon( @@ -94,7 +112,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_youtube_probe_title)) }, supportingContent = { Text(stringResource(R.string.debug_youtube_probe_desc_short)) }, modifier = Modifier.clickable(onClick = onOpenYouTubeDebug), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -109,7 +129,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_bili_api)) }, supportingContent = { Text(stringResource(R.string.debug_bili_api_desc)) }, modifier = Modifier.clickable(onClick = onOpenBiliDebug), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -124,7 +146,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_netease_api)) }, supportingContent = { Text(stringResource(R.string.debug_netease_api_desc)) }, modifier = Modifier.clickable(onClick = onOpenNeteaseDebug), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -139,7 +163,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_search_api)) }, supportingContent = { Text(stringResource(R.string.debug_search_api_desc)) }, modifier = Modifier.clickable(onClick = onOpenSearchDebug), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -154,7 +180,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_view_logs)) }, supportingContent = { Text(stringResource(R.string.debug_view_logs_desc)) }, modifier = Modifier.clickable(onClick = onOpenLogs), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -169,7 +197,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.crash_log_title)) }, supportingContent = { Text(stringResource(R.string.crash_log_desc)) }, modifier = Modifier.clickable(onClick = onOpenCrashLogs), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) ListItem( @@ -184,7 +214,9 @@ fun DebugHomeScreen( headlineContent = { Text(stringResource(R.string.debug_test_exception)) }, supportingContent = { Text(stringResource(R.string.debug_test_exception_desc)) }, modifier = Modifier.clickable(onClick = onTestExceptionHandler), - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) } } @@ -211,9 +243,7 @@ fun DebugHomeScreen( supportingContent = { Text(stringResource(R.string.debug_hide_hint)) }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ) + colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt new file mode 100644 index 00000000..60706e3a --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt @@ -0,0 +1,493 @@ +package moe.ouom.neriplayer.ui.screen.debug + +import android.content.Context +import android.content.ContextWrapper +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.core.di.AppContainer +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.data.ListenTogetherPreferences +import moe.ouom.neriplayer.listentogether.ListenTogetherConnectionState +import moe.ouom.neriplayer.listentogether.ListenTogetherRoomSettings +import moe.ouom.neriplayer.listentogether.ListenTogetherRoomStatuses +import moe.ouom.neriplayer.listentogether.ListenTogetherSessionManager +import moe.ouom.neriplayer.listentogether.buildListenTogetherInviteUri +import moe.ouom.neriplayer.listentogether.normalizeListenTogetherRoomId +import moe.ouom.neriplayer.listentogether.resolveListenTogetherBaseUrl +import moe.ouom.neriplayer.listentogether.validateListenTogetherNickname +import moe.ouom.neriplayer.listentogether.validateListenTogetherRoomId +import moe.ouom.neriplayer.listentogether.validateListenTogetherUserUuid + +@Composable +fun ListenTogetherRoomPanel( + modifier: Modifier = Modifier, + showBaseUrlEditor: Boolean = false +) { + val context = LocalContext.current + val activity = remember(context) { context.findComponentActivity() } + val clipboard = LocalClipboardManager.current + val sessionManager = remember { AppContainer.listenTogetherSessionManager } + val preferences = remember { AppContainer.listenTogetherPreferences } + val sessionState by sessionManager.sessionState.collectAsState() + val roomState by sessionManager.roomState.collectAsState() + val savedBaseUrl by preferences.workerBaseUrlFlow.collectAsState(initial = "") + val savedUserUuid by preferences.userUuidFlow.collectAsState(initial = "") + val savedNickname by preferences.nicknameFlow.collectAsState(initial = "") + val savedAllowMemberControl by preferences.allowMemberControlFlow.collectAsState(initial = true) + val savedAutoPauseOnMemberChange by preferences.autoPauseOnMemberChangeFlow.collectAsState(initial = true) + val savedShareAudioLinks by preferences.shareAudioLinksFlow.collectAsState(initial = true) + val currentQueue by PlayerManager.currentQueueFlow.collectAsState() + val currentSong by PlayerManager.currentSongFlow.collectAsState() + val isPlaying by PlayerManager.isPlayingFlow.collectAsState() + val positionMs by PlayerManager.playbackPositionFlow.collectAsState() + + var baseUrl by rememberSaveable { mutableStateOf("") } + var roomIdInput by rememberSaveable { mutableStateOf("") } + var userUuid by rememberSaveable { mutableStateOf("") } + var nickname by rememberSaveable { mutableStateOf("") } + var allowMemberControl by rememberSaveable { mutableStateOf(true) } + var autoPauseOnMemberChange by rememberSaveable { mutableStateOf(true) } + var shareAudioLinks by rememberSaveable { mutableStateOf(true) } + var runningActionResId by remember { mutableStateOf(null) } + + val isInRoom = !sessionState.roomId.isNullOrBlank() + val role = resolveListenTogetherRole(sessionState.userUuid, sessionState.role, roomState) + val isController = role == "controller" + val effectiveBaseUrl = resolveListenTogetherBaseUrl(baseUrl) + val roomSettings = roomState?.settings ?: ListenTogetherRoomSettings( + allowMemberControl = allowMemberControl, + autoPauseOnMemberChange = autoPauseOnMemberChange, + shareAudioLinks = shareAudioLinks + ) + val inviteUri = remember(sessionState.roomId, sessionState.nickname, effectiveBaseUrl) { + sessionState.roomId?.let { + buildListenTogetherInviteUri(it, sessionState.nickname, effectiveBaseUrl) + } + } + + LaunchedEffect(savedBaseUrl) { if (baseUrl.isBlank()) baseUrl = resolveListenTogetherBaseUrl(savedBaseUrl) } + LaunchedEffect(savedUserUuid) { + if (userUuid.isBlank()) { + userUuid = if (savedUserUuid.isBlank()) { + preferences.getOrCreateUserUuid() + } else { + savedUserUuid + } + } + } + LaunchedEffect(savedNickname) { + if (nickname.isBlank()) { + nickname = if (savedNickname.isBlank()) { + preferences.getOrCreateNickname() + } else { + savedNickname + } + } + } + LaunchedEffect(sessionState.roomId) { sessionState.roomId?.let { roomIdInput = it } } + LaunchedEffect(savedAllowMemberControl, savedAutoPauseOnMemberChange, savedShareAudioLinks, isInRoom) { + if (!isInRoom) { + allowMemberControl = savedAllowMemberControl + autoPauseOnMemberChange = savedAutoPauseOnMemberChange + shareAudioLinks = savedShareAudioLinks + } + } + LaunchedEffect(roomState?.settings, isController) { + if (isController && roomState != null) { + allowMemberControl = roomSettings.allowMemberControl + autoPauseOnMemberChange = roomSettings.autoPauseOnMemberChange + shareAudioLinks = roomSettings.shareAudioLinks + } + } + Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f))) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (showBaseUrlEditor) { + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + label = { Text(stringResource(R.string.listen_together_worker_base_url)) }, + singleLine = true + ) + } + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it.trim().take(24) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + label = { Text(stringResource(R.string.listen_together_nickname)) }, + singleLine = true + ) + OutlinedTextField( + value = roomIdInput, + onValueChange = { roomIdInput = normalizeListenTogetherRoomId(it).take(6) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + label = { Text(stringResource(R.string.listen_together_room_id)) }, + singleLine = true, + readOnly = isInRoom + ) + runningActionResId?.let { resId -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(resId), style = MaterialTheme.typography.bodySmall) + } + } + validateListenTogetherNickname(nickname)?.let { ErrorText(it) } + if (!isInRoom) validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { ErrorText(it) } + if (!isInRoom) RoomActions( + runningActionResId = runningActionResId, + currentQueue = currentQueue, + currentSong = currentSong, + isPlaying = isPlaying, + positionMs = positionMs, + activity = activity, + userUuid = userUuid, + nickname = nickname, + roomIdInput = roomIdInput, + effectiveBaseUrl = effectiveBaseUrl, + roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + sessionState = sessionState, + preferences = preferences, + sessionManager = sessionManager, + onRunningActionChange = { runningActionResId = it } + ) + if (isInRoom) ConnectedActions(runningActionResId, effectiveBaseUrl, nickname, roomIdInput, sessionState, sessionManager, preferences, activity) { runningActionResId = it } + if (isController) { + TextButton( + onClick = { + val roomId = sessionState.roomId ?: return@TextButton + val inviteText = buildString { + append(context.getString(R.string.listen_together_invite_share_text, sessionState.nickname ?: context.getString(R.string.listen_together_title), roomId)) + inviteUri?.let { append("\n"); append(it) } + } + clipboard.setText(AnnotatedString(inviteText)) + Toast.makeText(context, context.getString(R.string.listen_together_invite_copied), Toast.LENGTH_SHORT).show() + }, + enabled = !sessionState.roomId.isNullOrBlank(), + modifier = Modifier.padding(horizontal = 12.dp) + ) { Text(stringResource(R.string.listen_together_copy_invite)) } + } + if (isController || !isInRoom) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + SettingsSection( + settings = if (isInRoom) roomSettings else ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + enabled = runningActionResId == null && (!isInRoom || isController), + onSettingsChange = { updated -> + allowMemberControl = updated.allowMemberControl + autoPauseOnMemberChange = updated.autoPauseOnMemberChange + shareAudioLinks = updated.shareAudioLinks + activity?.lifecycleScope?.launch { + runCatching { + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, updated) + if (isInRoom && isController) { + val result = sessionManager.updateRoomSettings(updated) + check(result.ok) { result.error ?: "websocket unavailable" } + } + }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + } + ) + } + StatusSection(sessionState, roomState, role, currentSong?.name, isPlaying) + MemberSection(roomState?.members?.sortedBy { it.joinedAt }.orEmpty()) + } + } +} + +@Composable +private fun RoomActions( + runningActionResId: Int?, + currentQueue: List, + currentSong: moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem?, + isPlaying: Boolean, + positionMs: Long, + activity: ComponentActivity?, + userUuid: String, + nickname: String, + roomIdInput: String, + effectiveBaseUrl: String, + roomSettings: ListenTogetherRoomSettings, + sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, + preferences: ListenTogetherPreferences, + sessionManager: ListenTogetherSessionManager, + onRunningActionChange: (Int?) -> Unit +) { + val context = LocalContext.current + val userUuidError = validateListenTogetherUserUuid(userUuid) + val nicknameError = validateListenTogetherNickname(nickname) + val roomIdError = validateListenTogetherRoomId(roomIdInput) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button(onClick = { + activity?.lifecycleScope?.launch { + onRunningActionChange(R.string.listen_together_creating_room) + runCatching { + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) + sessionManager.createRoom(effectiveBaseUrl, userUuid, nickname, currentQueue, currentQueue.indexOfFirst { it == currentSong }.takeIf { it >= 0 } ?: 0, positionMs, isPlaying, roomSettings) + sessionManager.connectWebSocket() + }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, enabled = runningActionResId == null && currentQueue.isNotEmpty() && userUuidError == null && nicknameError == null, modifier = Modifier.weight(1f)) { + Icon(Icons.Outlined.PlayArrow, contentDescription = null); Text(stringResource(R.string.listen_together_create_and_connect)) + } + Button(onClick = { + activity?.lifecycleScope?.launch { + onRunningActionChange(R.string.listen_together_joining_room) + runCatching { + val targetRoomId = normalizeListenTogetherRoomId(roomIdInput) + val currentRoomId = sessionState.roomId?.let(::normalizeListenTogetherRoomId) + if (currentRoomId != null && currentRoomId == targetRoomId) { + Toast.makeText(context, context.getString(R.string.listen_together_same_room_join_ignored, targetRoomId), Toast.LENGTH_SHORT).show() + return@runCatching + } + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) + PlayerManager.resetForListenTogetherJoin() + sessionManager.leaveRoom() + sessionManager.joinRoom(effectiveBaseUrl, targetRoomId, userUuid, nickname) + sessionManager.connectWebSocket() + }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, enabled = runningActionResId == null && roomIdInput.isNotBlank() && userUuidError == null && nicknameError == null && roomIdError == null, modifier = Modifier.weight(1f)) { + Icon(Icons.Outlined.Link, contentDescription = null); Text(stringResource(R.string.listen_together_join_and_connect)) + } + } +} + +@Composable +private fun ConnectedActions( + runningActionResId: Int?, + effectiveBaseUrl: String, + nickname: String, + roomIdInput: String, + sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, + sessionManager: ListenTogetherSessionManager, + preferences: ListenTogetherPreferences, + activity: ComponentActivity?, + onRunningActionChange: (Int?) -> Unit +) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button(onClick = { + activity?.lifecycleScope?.launch { + val roomId = sessionState.roomId ?: roomIdInput + if (roomId.isBlank()) return@launch + onRunningActionChange(R.string.listen_together_refreshing_room_state) + runCatching { + preferences.setNickname(nickname) + sessionManager.refreshRoomState(effectiveBaseUrl, roomId) + }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, enabled = runningActionResId == null, modifier = Modifier.weight(1f)) { + Icon(Icons.Outlined.Refresh, contentDescription = null); Text(stringResource(R.string.action_refresh)) + } + Button(onClick = { sessionManager.leaveRoom() }, enabled = sessionState.connectionState != ListenTogetherConnectionState.CONNECTING, modifier = Modifier.weight(1f)) { + Icon(Icons.Outlined.StopCircle, contentDescription = null); Text(stringResource(R.string.listen_together_leave_room)) + } + } +} + +private fun Context.findComponentActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.findComponentActivity() + else -> null +} + +@Composable +private fun SettingsSection(settings: ListenTogetherRoomSettings, enabled: Boolean, onSettingsChange: (ListenTogetherRoomSettings) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(stringResource(R.string.listen_together_settings_title), style = MaterialTheme.typography.titleSmall) + SettingToggleRow(stringResource(R.string.listen_together_setting_member_control_title), stringResource(R.string.listen_together_setting_member_control_desc), settings.allowMemberControl, enabled) { onSettingsChange(settings.copy(allowMemberControl = it)) } + SettingToggleRow(stringResource(R.string.listen_together_setting_auto_pause_title), stringResource(R.string.listen_together_setting_auto_pause_desc), settings.autoPauseOnMemberChange, enabled) { onSettingsChange(settings.copy(autoPauseOnMemberChange = it)) } + SettingToggleRow(stringResource(R.string.listen_together_setting_share_audio_links_title), stringResource(R.string.listen_together_setting_share_audio_links_desc), settings.shareAudioLinks, enabled) { onSettingsChange(settings.copy(shareAudioLinks = it)) } + } +} + +@Composable +private fun SettingToggleRow(title: String, subtitle: String, checked: Boolean, enabled: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun StatusSection( + sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, + roomState: moe.ouom.neriplayer.listentogether.ListenTogetherRoomState?, + role: String?, + fallbackTrackName: String?, + isPlaying: Boolean +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DebugValueRow(stringResource(R.string.listen_together_connection), stringResource(sessionState.connectionState.labelResId())) + DebugValueRow(stringResource(R.string.listen_together_role), stringResource(roleLabelResId(role))) + DebugValueRow(stringResource(R.string.listen_together_room_status), stringResource(roomStatusLabelResId(roomState?.roomStatus))) + DebugValueRow(stringResource(R.string.listen_together_room_id), sessionState.roomId ?: "-") + DebugValueRow(stringResource(R.string.listen_together_version), roomState?.version?.toString() ?: "-") + DebugValueRow(stringResource(R.string.listen_together_members), roomState?.members?.size?.toString() ?: "0") + DebugValueRow(stringResource(R.string.listen_together_queue_size), roomState?.queue?.size?.toString() ?: "0") + DebugValueRow(stringResource(R.string.listen_together_track), roomState?.track?.name ?: fallbackTrackName ?: "-") + DebugValueRow(stringResource(R.string.listen_together_playback), stringResource(if ((roomState?.playback?.state ?: if (isPlaying) "playing" else "paused") == "playing") R.string.listen_together_playback_playing else R.string.listen_together_playback_paused)) + sessionState.lastError?.takeIf { it.isNotBlank() }?.let { DebugValueRow(stringResource(R.string.listen_together_last_error), it) } + sessionState.roomNotice?.takeIf { it.isNotBlank() && !it.startsWith("member_joined:") && !it.startsWith("member_left:") }?.let { DebugValueRow(stringResource(R.string.listen_together_notice), it.toDisplayNotice(LocalContext.current)) } + } +} + +@Composable +private fun MemberSection(members: List) { + if (members.isEmpty()) return + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(stringResource(R.string.listen_together_member_list_title), style = MaterialTheme.typography.titleSmall) + members.forEach { member -> + DebugValueRow( + member.nickname.ifBlank { member.userUuid.ifBlank { member.userId.orEmpty() } }, + stringResource(roleLabelResId(member.role)) + ) + } + } +} + +@Composable private fun ErrorText(message: String) { Text(text = message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 20.dp)) } +@Composable private fun DebugValueRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary); Text(value, style = MaterialTheme.typography.bodySmall) } } +@Composable fun ListenTogetherDebugPanel(modifier: Modifier = Modifier) { ListenTogetherRoomPanel(modifier, false) } + +private suspend fun persistSettings( + preferences: ListenTogetherPreferences, + baseUrl: String, + userUuid: String, + nickname: String, + settings: ListenTogetherRoomSettings +) { + preferences.setWorkerBaseUrl(baseUrl) + preferences.setUserUuid(userUuid) + preferences.setNickname(nickname) + preferences.setAllowMemberControl(settings.allowMemberControl) + preferences.setAutoPauseOnMemberChange(settings.autoPauseOnMemberChange) + preferences.setShareAudioLinks(settings.shareAudioLinks) +} + +private fun ListenTogetherConnectionState.labelResId(): Int = when (this) { + ListenTogetherConnectionState.DISCONNECTED -> R.string.listen_together_connection_disconnected + ListenTogetherConnectionState.CONNECTING -> R.string.listen_together_connection_connecting + ListenTogetherConnectionState.CONNECTED -> R.string.listen_together_connection_connected +} + +private fun roleLabelResId(role: String?): Int = when (role) { + "controller" -> R.string.listen_together_role_controller + "listener" -> R.string.listen_together_role_listener + else -> R.string.listen_together_role_none +} + +private fun resolveListenTogetherRole(userUuid: String?, fallbackRole: String?, roomState: moe.ouom.neriplayer.listentogether.ListenTogetherRoomState?): String? { + val sessionUserId = userUuid?.trim()?.takeIf { it.isNotBlank() } + val controllerUserId = roomState?.controllerUserUuid?.trim()?.takeIf { it.isNotBlank() } + ?: roomState?.controllerUserId?.trim()?.takeIf { it.isNotBlank() } + return if (sessionUserId != null && controllerUserId != null) if (sessionUserId == controllerUserId) "controller" else "listener" else fallbackRole +} + +private fun roomStatusLabelResId(status: String?): Int = when (status) { + ListenTogetherRoomStatuses.CONTROLLER_OFFLINE -> R.string.listen_together_room_status_controller_offline + ListenTogetherRoomStatuses.CLOSED -> R.string.listen_together_room_status_closed + else -> R.string.listen_together_room_status_active +} + +private fun String.toDisplayNotice(context: android.content.Context): String = when { + startsWith("controller_offline:") -> context.getString(R.string.listen_together_notice_controller_offline, substringAfter(':').toLongOrNull() ?: 10L) + startsWith("member_joined:") -> context.getString(R.string.listen_together_notice_member_joined, substringAfter(':')) + startsWith("member_left:") -> context.getString(R.string.listen_together_notice_member_left, substringAfter(':')) + this == "controller_reconnected" -> context.getString(R.string.listen_together_notice_controller_reconnected) + this == "controller_timeout" || this == "room_closed" -> context.getString(R.string.listen_together_notice_room_closed) + this == "controller_offline" -> context.getString(R.string.listen_together_notice_controller_offline, 10L) + else -> this +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt index 3d58faed..92fae98e 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt @@ -27,6 +27,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -106,8 +107,11 @@ import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.BluetoothAudio import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.automirrored.outlined.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -193,6 +197,8 @@ import moe.ouom.neriplayer.data.BackgroundImageStorage import moe.ouom.neriplayer.data.YOUTUBE_AUTH_STALE_AFTER_MS import moe.ouom.neriplayer.data.YouTubeAuthHealth import moe.ouom.neriplayer.data.YouTubeAuthState +import moe.ouom.neriplayer.listentogether.isDefaultListenTogetherBaseUrl +import moe.ouom.neriplayer.listentogether.resolveListenTogetherBaseUrl import moe.ouom.neriplayer.ui.LocalMiniPlayerHeight import moe.ouom.neriplayer.ui.viewmodel.debug.NeteaseAuthEvent import moe.ouom.neriplayer.ui.viewmodel.debug.NeteaseAuthViewModel @@ -510,6 +516,11 @@ fun SettingsScreen( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val context = LocalContext.current val scope = rememberCoroutineScope() + val listenTogetherPreferences = remember { AppContainer.listenTogetherPreferences } + val listenTogetherApi = remember { AppContainer.listenTogetherApi } + val listenTogetherSessionManager = remember { AppContainer.listenTogetherSessionManager } + val listenTogetherSessionState by listenTogetherSessionManager.sessionState.collectAsState() + val listenTogetherWorkerBaseUrl by listenTogetherPreferences.workerBaseUrlFlow.collectAsState(initial = "") val internationalEnabled by AppContainer.settingsRepo.internationalizationEnabledFlow .collectAsState(initial = false) @@ -543,6 +554,12 @@ fun SettingsScreen( var networkExpanded by rememberSaveable { mutableStateOf(false) } val networkArrowRotation by animateFloatAsState(targetValue = if (networkExpanded) 180f else 0f, label = "network_arrow") + var listenTogetherExpanded by rememberSaveable { mutableStateOf(false) } + val listenTogetherArrowRotation by animateFloatAsState( + targetValue = if (listenTogetherExpanded) 180f else 0f, + label = "listen_together_arrow" + ) + // 音质设置菜单的状态 var audioQualityExpanded by rememberSaveable { mutableStateOf(false) } val audioQualityArrowRotation by animateFloatAsState(targetValue = if (audioQualityExpanded) 180f else 0f, label = "audio_quality_arrow") @@ -592,6 +609,11 @@ fun SettingsScreen( var showDpiDialog by remember { mutableStateOf(false) } var showGitHubConfigDialog by remember { mutableStateOf(false) } var showClearGitHubConfigDialog by remember { mutableStateOf(false) } + var showListenTogetherResetUuidDialog by remember { mutableStateOf(false) } + var showListenTogetherServerDialog by remember { mutableStateOf(false) } + var listenTogetherServerInput by rememberSaveable { mutableStateOf("") } + var listenTogetherServerTesting by remember { mutableStateOf(false) } + var listenTogetherServerTestMessage by remember { mutableStateOf(null) } // ------------------------------------ val neteaseVm: NeteaseAuthViewModel = viewModel() @@ -636,6 +658,12 @@ fun SettingsScreen( } ) + LaunchedEffect(listenTogetherWorkerBaseUrl) { + if (listenTogetherServerInput != listenTogetherWorkerBaseUrl) { + listenTogetherServerInput = listenTogetherWorkerBaseUrl + } + } + val biliWebLoginLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> @@ -1964,6 +1992,47 @@ fun SettingsScreen( } } + item { + ExpandableHeader( + icon = Icons.Outlined.Cloud, + title = stringResource(R.string.listen_together_title), + subtitleCollapsed = stringResource(R.string.settings_listen_together_expand), + subtitleExpanded = stringResource(R.string.settings_login_platforms_collapse), + expanded = listenTogetherExpanded, + onToggle = { listenTogetherExpanded = !listenTogetherExpanded }, + arrowRotation = listenTogetherArrowRotation + ) + } + + item { + AnimatedVisibility( + visible = listenTogetherExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ListenTogetherSettingsSection( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + isUsingDefaultServer = listenTogetherServerInput.isBlank() || + isDefaultListenTogetherBaseUrl(resolveListenTogetherBaseUrl(listenTogetherServerInput)), + isInRoom = !listenTogetherSessionState.roomId.isNullOrBlank(), + testing = listenTogetherServerTesting, + testMessage = listenTogetherServerTestMessage, + onOpenServerDialog = { + listenTogetherServerTestMessage = null + showListenTogetherServerDialog = true + }, + onResetIdentity = { + if (listenTogetherSessionState.roomId.isNullOrBlank()) { + showListenTogetherResetUuidDialog = true + } + } + ) + } + } + item { ExpandableHeader( @@ -4304,6 +4373,249 @@ fun SettingsScreen( } ) } + + if (showListenTogetherResetUuidDialog) { + AlertDialog( + onDismissRequest = { showListenTogetherResetUuidDialog = false }, + title = { Text(stringResource(R.string.listen_together_reset_uuid)) }, + text = { Text(stringResource(R.string.listen_together_reset_uuid_confirm)) }, + confirmButton = { + HapticTextButton( + onClick = { + scope.launch { + listenTogetherPreferences.resetUserUuid() + showListenTogetherResetUuidDialog = false + Toast.makeText( + context, + context.getString(R.string.listen_together_reset_uuid_done), + Toast.LENGTH_SHORT + ).show() + } + } + ) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + HapticTextButton(onClick = { showListenTogetherResetUuidDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + + if (showListenTogetherServerDialog) { + AlertDialog( + onDismissRequest = { + if (!listenTogetherServerTesting) { + showListenTogetherServerDialog = false + listenTogetherServerInput = listenTogetherWorkerBaseUrl + listenTogetherServerTestMessage = null + } + }, + title = { Text(stringResource(R.string.settings_listen_together_server_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + if (listenTogetherServerInput.isBlank() || + isDefaultListenTogetherBaseUrl(resolveListenTogetherBaseUrl(listenTogetherServerInput)) + ) { + stringResource(R.string.settings_listen_together_server_default_desc) + } else { + stringResource(R.string.settings_listen_together_server_custom_desc) + } + ) + OutlinedTextField( + value = listenTogetherServerInput, + onValueChange = { + listenTogetherServerInput = it + listenTogetherServerTestMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.settings_listen_together_server_input_label)) }, + placeholder = { Text(stringResource(R.string.settings_listen_together_server_input_placeholder)) } + ) + if (listenTogetherServerTesting) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.settings_listen_together_server_testing), + style = MaterialTheme.typography.bodySmall + ) + } + } else { + listenTogetherServerTestMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + scope.launch { + listenTogetherServerTesting = true + val usingDefaultServer = listenTogetherServerInput.isBlank() || + isDefaultListenTogetherBaseUrl(resolveListenTogetherBaseUrl(listenTogetherServerInput)) + val result = listenTogetherApi.testServerAvailability( + resolveListenTogetherBaseUrl(listenTogetherServerInput) + ) + listenTogetherServerTesting = false + listenTogetherServerTestMessage = when { + result.ok && usingDefaultServer -> + context.getString(R.string.settings_listen_together_server_test_success_default) + result.ok -> + context.getString(R.string.settings_listen_together_server_test_success_custom) + result.message == "invalid_response" -> + context.getString(R.string.settings_listen_together_server_test_invalid) + else -> + context.getString( + R.string.settings_listen_together_server_test_failed, + result.message + ) + } + } + }, + enabled = !listenTogetherServerTesting + ) { + Text(stringResource(R.string.settings_listen_together_server_test)) + } + TextButton( + onClick = { + listenTogetherServerInput = "" + listenTogetherServerTestMessage = context.getString( + R.string.settings_listen_together_server_reset_done + ) + }, + enabled = !listenTogetherServerTesting + ) { + Text(stringResource(R.string.action_reset)) + } + } + } + }, + confirmButton = { + HapticTextButton( + onClick = { + scope.launch { + val normalizedInput = listenTogetherServerInput.trim() + listenTogetherPreferences.setWorkerBaseUrl(normalizedInput) + listenTogetherServerInput = normalizedInput + showListenTogetherServerDialog = false + listenTogetherServerTestMessage = null + Toast.makeText( + context, + context.getString(R.string.settings_listen_together_server_saved), + Toast.LENGTH_SHORT + ).show() + } + }, + enabled = !listenTogetherServerTesting + ) { + Text(stringResource(R.string.action_apply)) + } + }, + dismissButton = { + HapticTextButton( + onClick = { + showListenTogetherServerDialog = false + listenTogetherServerInput = listenTogetherWorkerBaseUrl + listenTogetherServerTestMessage = null + }, + enabled = !listenTogetherServerTesting + ) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } +} + +@Composable +private fun ListenTogetherSettingsSection( + modifier: Modifier = Modifier, + isUsingDefaultServer: Boolean, + isInRoom: Boolean, + testing: Boolean, + testMessage: String?, + onOpenServerDialog: () -> Unit, + onResetIdentity: () -> Unit +) { + val identityItemModifier = if (isInRoom) { + Modifier.alpha(0.5f) + } else { + Modifier.settingsItemClickable(onClick = onResetIdentity) + } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + ListItem( + modifier = Modifier.settingsItemClickable(onClick = onOpenServerDialog), + leadingContent = { + Icon( + imageVector = Icons.Outlined.Link, + contentDescription = stringResource(R.string.settings_listen_together_server_title), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + headlineContent = { Text(stringResource(R.string.settings_listen_together_server_title)) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (isUsingDefaultServer) { + stringResource(R.string.settings_listen_together_server_default_desc) + } else { + stringResource(R.string.settings_listen_together_server_custom_desc) + } + ) + testMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + trailingContent = { + if (testing) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + + ListItem( + modifier = identityItemModifier, + leadingContent = { + Icon( + imageVector = Icons.Outlined.RestartAlt, + contentDescription = stringResource(R.string.listen_together_reset_uuid), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + headlineContent = { Text(stringResource(R.string.listen_together_reset_uuid)) }, + supportingContent = { + Text( + if (isInRoom) { + stringResource(R.string.listen_together_reset_uuid_disabled) + } else { + stringResource(R.string.settings_listen_together_reset_identity_desc) + } + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } } @OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/NeteasePlaylistDetailViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/NeteasePlaylistDetailViewModel.kt index c8dfc9c5..415b5439 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/NeteasePlaylistDetailViewModel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/NeteasePlaylistDetailViewModel.kt @@ -80,7 +80,12 @@ data class SongItem( val originalLyric: String? = null, val originalTranslatedLyric: String? = null, val localFileName: String? = null, - val localFilePath: String? = null + val localFilePath: String? = null, + val channelId: String? = null, + val audioId: String? = null, + val subAudioId: String? = null, + val playlistContextId: String? = null, + val streamUrl: String? = null ) : Parcelable data class PlaylistDetailUiState( @@ -358,7 +363,9 @@ class PlaylistDetailViewModel(application: Application) : AndroidViewModel(appli album = "Netease$albumName", albumId = albumId, durationMs = duration, - coverUrl = cover + coverUrl = cover, + channelId = "netease", + audioId = id.toString() ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/YouTubeMusicPlaylistDetailViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/YouTubeMusicPlaylistDetailViewModel.kt index 7a11a8cc..a9204f15 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/YouTubeMusicPlaylistDetailViewModel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/YouTubeMusicPlaylistDetailViewModel.kt @@ -100,7 +100,10 @@ class YouTubeMusicPlaylistDetailViewModel(application: Application) : AndroidVie ), originalName = name, originalArtist = artist, - originalCoverUrl = coverUrl.ifBlank { playlist.coverUrl } + originalCoverUrl = coverUrl.ifBlank { playlist.coverUrl }, + channelId = "youtubeMusic", + audioId = videoId, + playlistContextId = playlist.playlistId.ifBlank { null } ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/tab/ExploreViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/tab/ExploreViewModel.kt index 872d5d85..ab21b9e5 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/tab/ExploreViewModel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/tab/ExploreViewModel.kt @@ -303,7 +303,9 @@ class ExploreViewModel(application: Application) : AndroidViewModel(application) albumId = 0L, album = albumObj?.optString("name").orEmpty(), durationMs = obj.optLong("dt"), - coverUrl = albumObj?.optString("picUrl")?.replace("http://", "https://") + coverUrl = albumObj?.optString("picUrl")?.replace("http://", "https://"), + channelId = "netease", + audioId = obj.optLong("id").toString() )) } return list @@ -401,7 +403,9 @@ private fun BiliClient.SearchVideoItem.toSongItem(): SongItem { album = PlayerManager.BILI_SOURCE_TAG, // 标记来源 albumId = 0L, durationMs = this.durationSec * 1000L, - coverUrl = this.coverUrl + coverUrl = this.coverUrl, + channelId = "bilibili", + audioId = this.aid.toString() ) } @@ -424,6 +428,8 @@ private fun YouTubeMusicSearchResult.toSongItem(app: Application): SongItem { mediaUri = buildYouTubeMusicMediaUri(videoId), originalName = title, originalArtist = displayArtist, - originalCoverUrl = coverUrl.ifBlank { null } + originalCoverUrl = coverUrl.ifBlank { null }, + channelId = "youtubeMusic", + audioId = videoId ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7ed43e8b..e1b7182e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1356,4 +1356,83 @@ %1$s - %2$s %1$d%% %1$d%% - %2$s + Listen Together + Worker Base URL + Current Nickname + Identity UUID + Reset Unique Identity + Resetting the unique identity will invalidate the old identity and replace it with a new one. Continue? + Unique identity has been reset. + Leave the room before resetting the unique identity. + Room ID + Connection + Disconnected + Connecting + Connected + Role + Controller + Listener + Not joined + Heartbeat + Members + Queue Size + Track + Playback + Playing + Paused + Last Error + Create + Join + Join Room + Leave + Copy Invite + Invite copied + %1$s invites you to join room %2$s in NeriPlayer. Open the app after copying this message, or use the link below. + Creating room + Joining room + Refreshing room state + Listen Together Invite + Do you want to join room %1$s? + Do you want to join %1$s\'s room %2$s? + Room Status + Active + Controller Offline + Closed + Notice + Controller is offline. Room closes in about %1$d minute(s) if not reconnected. + Controller reconnected. Sync has resumed. + Room closed because the controller did not return in time. + %1$s joined the room. + %1$s left the room. + You are already in room %1$s. + Room Settings + Member Control + Allow listeners to change tracks and seek. + Auto Pause + Pause automatically when a member joins or leaves. + Share Audio Links + Send playable audio links to listeners when available. + Members + The controller disabled member playback control. + The controller is offline. Try again after reconnection. + Local playback is disabled while Listen Together is active. + Joining Listen Together + Syncing Listen Together state + Listen Together active + This action is temporarily unavailable. Please try again. + Configure the server address and unique identity + Listen Together Server + Leave this empty to use the built-in default server. Invite links will not include a server address. + A custom Listen Together server is active. Invite links will include the server address. + Server Address + Leave empty to use the built-in default server + Test Availability + Listen Together server setting saved. + Reverted to the built-in default server. + The built-in default server is reachable. + The custom Listen Together server is reachable. + The address responded, but it does not appear to be a valid Listen Together server. + Server test failed: %1$s + Testing server availability + Resetting will generate a new unique identity and invalidate the previous one. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7246f24d..a2c53926 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1364,4 +1364,83 @@ %1$s - %2$s %1$d%% %1$d%% - %2$s + 一起听 + Worker Base URL + 当前昵称 + 身份 UUID + 重置唯一身份标识符 + 重置后旧的唯一身份标识符将失效,并替换为新的身份标识,是否继续? + 唯一身份标识符已重置。 + 当前在房间中,需先离开房间后才能重置唯一身份标识符。 + 房间 ID + 连接状态 + 未连接 + 连接中 + 已连接 + 角色 + 房主 + 听众 + 未入房 + 房间状态 + 正常 + 房主离线 + 已关闭 + 心跳 + 成员数 + 队列数量 + 当前歌曲 + 播放状态 + 播放中 + 已暂停 + 最近错误 + 提示 + 房主当前离线,如果大约 %1$d 分钟内没有重连,房间会自动关闭。 + 房主已重连,同步已恢复。 + 房主长时间未返回,房间已自动关闭。 + 你已经在房间 %1$s 中,无需重复加入。 + 创建 + 加入 + 加入房间 + 离开 + 复制邀请信息 + 邀请信息已复制 + %1$s 邀请你在 NeriPlayer 中加入房间 %2$s。复制后打开 App 即可识别,或直接使用下面的链接。 + 正在创建房间 + 正在加入房间 + 正在刷新房间状态 + 一起听邀请 + 是否加入房间 %1$s? + 是否加入 %1$s 的房间 %2$s? + 用户 %1$s 加入了房间。 + 用户 %1$s 离开了房间。 + 一起听设置 + 允许听众操作 + 允许听众切歌、播放暂停和拖动进度。 + 自动暂停 + 当成员进入或离开时自动暂停。 + 下发音频链接 + 当可用时向听众下发可播放的音频直链。 + 已加入成员 + 房主已关闭听众操作权限。 + 房主当前离线,请等待重连后再试。 + 处于一起听时不可切换到本地音乐播放。 + 正在加入一起听 + 正在同步一起听状态 + 当前处于一起听 + 当前页面状态不可执行该操作,请稍后重试。 + 配置服务器地址和唯一身份标识符 + 一起听服务器 + 留空即可使用内置默认服务器。 + 当前使用自定义一起听服务器,分享邀请时会附带服务器地址。 + 服务器地址 + 留空使用内置默认服务器 + 测试可用性 + 一起听服务器设置已保存。 + 已恢复使用内置默认服务器。 + 内置默认服务器可用。 + 自定义一起听服务器可用。 + 该地址已响应,但看起来不是可用的一起听服务端。 + 服务器测试失败:%1$s + 正在测试服务器可用性 + 重置后将生成新的唯一身份标识符,旧标识符将失效。 From 3441211ed101ddaa7bd45d31d9e39eebedaf157c Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Fri, 27 Mar 2026 12:51:44 +0800 Subject: [PATCH 02/11] feat: refine listen together reconnect handling and debug tooling --- .../ouom/neriplayer/activity/MainActivity.kt | 86 +- .../ListenTogetherSessionManager.kt | 37 +- .../neriplayer/navigation/Destinations.kt | 1 + .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 5 + .../ui/screen/debug/DebugHomeScreen.kt | 7 +- .../screen/debug/ListenTogetherDebugPanel.kt | 924 ++++++++++++++---- app/src/main/res/values-en/strings.xml | 47 + app/src/main/res/values/strings.xml | 47 + 8 files changed, 928 insertions(+), 226 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt index cafa2d0a..d80b60d8 100644 --- a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt +++ b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt @@ -289,7 +289,7 @@ class MainActivity : ComponentActivity() { val listenTogetherRoomState by AppContainer.listenTogetherSessionManager.roomState.collectAsState() val isListenTogetherRoomActive = !listenTogetherSessionState.roomId.isNullOrBlank() var hadActiveListenTogetherRoom by rememberSaveable { mutableStateOf(false) } - var lastShownListenTogetherMemberNotice by rememberSaveable { mutableStateOf(null) } + var lastShownListenTogetherNotice by rememberSaveable { mutableStateOf(null) } val effectiveListenTogetherStatus = when { joiningInvite -> getString(R.string.listen_together_status_joining) !listenTogetherStatus.isNullOrBlank() -> listenTogetherStatus @@ -299,11 +299,8 @@ class MainActivity : ComponentActivity() { isListenTogetherRoomActive -> getString(R.string.listen_together_status_active) else -> null } - val showLeaveListenTogetherAction = isListenTogetherRoomActive && ( - dialogMessage == getString(R.string.listen_together_error_controller_offline) || - dialogMessage.contains("一起听", ignoreCase = false) || - dialogMessage.contains("controller offline", ignoreCase = true) - ) + val showLeaveListenTogetherAction = isListenTogetherRoomActive && + shouldOfferListenTogetherLeaveAction(dialogMessage) // 初始化异常处理器事件监听 LaunchedEffect(Unit) { @@ -395,16 +392,17 @@ class MainActivity : ComponentActivity() { listenTogetherRoomState?.version ) { val notice = listenTogetherSessionState.roomNotice ?: return@LaunchedEffect - if (!notice.startsWith("member_joined:") && !notice.startsWith("member_left:")) { + val displayNotice = notice.toListenTogetherDisplayMessage() + if (displayNotice.isBlank()) { return@LaunchedEffect } val noticeKey = "${listenTogetherRoomState?.version ?: -1L}:$notice" - if (lastShownListenTogetherMemberNotice == noticeKey) { + if (lastShownListenTogetherNotice == noticeKey) { return@LaunchedEffect } - lastShownListenTogetherMemberNotice = noticeKey + lastShownListenTogetherNotice = noticeKey showListenTogetherStatusToast( - message = notice.toListenTogetherNoticeDisplay(), + message = displayNotice, atBottom = true ) } @@ -480,8 +478,6 @@ class MainActivity : ComponentActivity() { val userUuid = preferences.getOrCreateUserUuid() val nickname = preferences.getOrCreateNickname() preferences.setWorkerBaseUrl(baseUrl) - PlayerManager.resetForListenTogetherJoin() - sessionManager.leaveRoom() updateListenTogetherStatus(getString(R.string.listen_together_status_syncing)) sessionManager.joinRoom( baseUrl = baseUrl, @@ -493,7 +489,9 @@ class MainActivity : ComponentActivity() { clearPendingListenTogetherInvite() } catch (error: Throwable) { updateListenTogetherStatus(null) - dialogMessage = error.message ?: error.javaClass.simpleName + dialogMessage = ( + error.message ?: error.javaClass.simpleName + ).toListenTogetherDisplayMessage() showDialog = true } finally { joiningInvite = false @@ -686,21 +684,53 @@ class MainActivity : ComponentActivity() { }.show() } - private fun String.toListenTogetherNoticeDisplay(): String = when { - startsWith("controller_offline:") -> - getString( - R.string.listen_together_notice_controller_offline, - substringAfter(':').toLongOrNull() ?: 10L - ) - startsWith("member_joined:") -> - getString(R.string.listen_together_notice_member_joined, substringAfter(':')) - startsWith("member_left:") -> - getString(R.string.listen_together_notice_member_left, substringAfter(':')) - this == "controller_reconnected" -> - getString(R.string.listen_together_notice_controller_reconnected) - this == "controller_timeout" || this == "room_closed" -> - getString(R.string.listen_together_notice_room_closed) - else -> this + private fun shouldOfferListenTogetherLeaveAction(message: String): Boolean { + return message == getString(R.string.listen_together_error_controller_offline) || + message == getString(R.string.listen_together_error_unauthorized) || + message == getString(R.string.listen_together_error_room_not_found) || + message == getString(R.string.listen_together_notice_room_closed) || + message == getString(R.string.listen_together_error_reconnecting) || + message == getString(R.string.listen_together_error_rejoining) + } + + private fun String.toListenTogetherDisplayMessage(): String { + val normalized = trim() + val lowered = normalized.lowercase() + return when { + startsWith("controller_offline:") -> + getString( + R.string.listen_together_notice_controller_offline, + substringAfter(':').toLongOrNull() ?: 10L + ) + startsWith("member_joined:") -> + getString(R.string.listen_together_notice_member_joined, substringAfter(':')) + startsWith("member_left:") -> + getString(R.string.listen_together_notice_member_left, substringAfter(':')) + normalized == "controller_reconnected" -> + getString(R.string.listen_together_notice_controller_reconnected) + normalized == "controller_timeout" || + normalized == "room_closed" || + "room closed" in lowered -> + getString(R.string.listen_together_notice_room_closed) + "unauthorized" in lowered || + "http=401" in lowered || + "(401)" in lowered -> + getString(R.string.listen_together_error_unauthorized) + "room not initialized" in lowered || + "not found in do" in lowered -> + getString(R.string.listen_together_error_room_not_found) + "controller offline" in lowered -> + getString(R.string.listen_together_error_controller_offline) + "member control disabled" in lowered -> + getString(R.string.listen_together_error_member_control_disabled) + "一起听连接不可用" in normalized || + ("listen together" in lowered && "reconnect" in lowered) -> + getString(R.string.listen_together_error_reconnecting) + "一起听连接已失效" in normalized || + ("rejoin" in lowered && "room" in lowered) -> + getString(R.string.listen_together_error_rejoining) + else -> normalized + } } private fun presentListenTogetherInvite(invite: ListenTogetherInvite) { diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt index 682ab539..e41b4228 100644 --- a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt @@ -1318,7 +1318,9 @@ class ListenTogetherSessionManager( private fun isTerminalReconnectError(errorMessage: String?): Boolean { val normalized = errorMessage?.trim()?.lowercase().orEmpty() if (normalized.isBlank()) return false - return TERMINAL_RECONNECT_ERROR_MARKERS.any(normalized::contains) + return isUnauthorizedReconnectError(normalized) || + isClosedRoomReconnectError(normalized) || + isMissingRoomReconnectError(normalized) } private fun noteOutboundSync() { @@ -1609,25 +1611,20 @@ class ListenTogetherSessionManager( "PAUSE", "SEEK" ) - private val TERMINAL_RECONNECT_ERROR_MARKERS = setOf( - "401 unauthorized", - "http=401", - "(401)", - "unauthorized", - "\"error\":\"unauthorized\"", - "room closed", - "\"error\":\"room closed\"", - "room not initialized", - "not found in do", - "room not found", - "\"error\":\"not found in do\"", - "\"error\":\"room not found\"", - "not found", - "http=404", - "(404)", - "http=410", - "(410)" - ) + private fun isUnauthorizedReconnectError(normalized: String): Boolean { + return "unauthorized" in normalized + } + + private fun isClosedRoomReconnectError(normalized: String): Boolean { + return "room closed" in normalized || "http=410" in normalized || "(410)" in normalized + } + + private fun isMissingRoomReconnectError(normalized: String): Boolean { + return "room not initialized" in normalized || + "\"error\":\"room not initialized\"" in normalized || + "not found in do" in normalized || + "\"error\":\"not found in do\"" in normalized + } private fun reconnectDelayMs(attempt: Int): Long { return when (attempt) { diff --git a/app/src/main/java/moe/ouom/neriplayer/navigation/Destinations.kt b/app/src/main/java/moe/ouom/neriplayer/navigation/Destinations.kt index b82339ed..609febf4 100644 --- a/app/src/main/java/moe/ouom/neriplayer/navigation/Destinations.kt +++ b/app/src/main/java/moe/ouom/neriplayer/navigation/Destinations.kt @@ -40,6 +40,7 @@ sealed class Destinations(val route: String, val labelResId: Int) { // DEBUG data object Debug : Destinations("debug", moe.ouom.neriplayer.R.string.debug_title) + data object DebugListenTogether : Destinations("debug/listen_together", moe.ouom.neriplayer.R.string.listen_together_title) data object DebugYouTube : Destinations("debug/youtube", moe.ouom.neriplayer.R.string.common_youtube) data object DebugBili : Destinations("debug/bili", moe.ouom.neriplayer.R.string.debug_bili_api) data object DebugNetease : Destinations("debug/netease", moe.ouom.neriplayer.R.string.debug_netease_api) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt index bd8265b5..f96995d8 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt @@ -148,6 +148,7 @@ import moe.ouom.neriplayer.ui.screen.debug.BiliApiProbeScreen import moe.ouom.neriplayer.ui.screen.debug.DebugHomeScreen import moe.ouom.neriplayer.ui.screen.debug.LogListScreen import moe.ouom.neriplayer.ui.screen.debug.CrashLogListScreen +import moe.ouom.neriplayer.ui.screen.debug.ListenTogetherDebugScreen import moe.ouom.neriplayer.ui.screen.debug.NeteaseApiProbeScreen import moe.ouom.neriplayer.ui.screen.debug.SearchApiProbeScreen import moe.ouom.neriplayer.ui.screen.debug.YouTubeApiProbeScreen @@ -1499,6 +1500,9 @@ fun NeriApp( } ) { DebugHomeScreen( + onOpenListenTogetherDebug = { + navController.navigate(Destinations.DebugListenTogether.route) + }, onOpenYouTubeDebug = { navController.navigate(Destinations.DebugYouTube.route) }, @@ -1521,6 +1525,7 @@ fun NeriApp( } ) } + composable(Destinations.DebugListenTogether.route) { ListenTogetherDebugScreen() } composable(Destinations.DebugYouTube.route) { YouTubeApiProbeScreen() } composable(Destinations.DebugBili.route) { BiliApiProbeScreen() } composable(Destinations.DebugNetease.route) { NeteaseApiProbeScreen() } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt index aff1bdce..99094af7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/DebugHomeScreen.kt @@ -66,6 +66,7 @@ import moe.ouom.neriplayer.ui.LocalMiniPlayerHeight @Composable fun DebugHomeScreen( + onOpenListenTogetherDebug: () -> Unit, onOpenYouTubeDebug: () -> Unit, onOpenBiliDebug: () -> Unit, onOpenNeteaseDebug: () -> Unit, @@ -115,15 +116,13 @@ fun DebugHomeScreen( ) }, headlineContent = { Text(stringResource(R.string.listen_together_title)) }, + supportingContent = { Text(stringResource(R.string.listen_together_debug_entry_desc)) }, + modifier = Modifier.clickable(onClick = onOpenListenTogetherDebug), colors = ListItemDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ) ) - ListenTogetherDebugPanel( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) - ListItem( leadingContent = { Icon( diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt index 60706e3a..6f997384 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt @@ -4,18 +4,37 @@ import android.content.Context import android.content.ContextWrapper import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.NetworkCheck import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.SettingsEthernet import androidx.compose.material.icons.outlined.StopCircle -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -23,6 +42,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -35,11 +55,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch @@ -48,15 +72,54 @@ import moe.ouom.neriplayer.core.di.AppContainer import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.data.ListenTogetherPreferences import moe.ouom.neriplayer.listentogether.ListenTogetherConnectionState +import moe.ouom.neriplayer.listentogether.ListenTogetherMember import moe.ouom.neriplayer.listentogether.ListenTogetherRoomSettings +import moe.ouom.neriplayer.listentogether.ListenTogetherRoomState import moe.ouom.neriplayer.listentogether.ListenTogetherRoomStatuses import moe.ouom.neriplayer.listentogether.ListenTogetherSessionManager +import moe.ouom.neriplayer.listentogether.ListenTogetherTrack import moe.ouom.neriplayer.listentogether.buildListenTogetherInviteUri import moe.ouom.neriplayer.listentogether.normalizeListenTogetherRoomId import moe.ouom.neriplayer.listentogether.resolveListenTogetherBaseUrl import moe.ouom.neriplayer.listentogether.validateListenTogetherNickname import moe.ouom.neriplayer.listentogether.validateListenTogetherRoomId import moe.ouom.neriplayer.listentogether.validateListenTogetherUserUuid +import moe.ouom.neriplayer.ui.LocalMiniPlayerHeight +import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private data class DebugField( + val label: String, + val value: String +) + +@Composable +fun ListenTogetherDebugScreen() { + val miniH = LocalMiniPlayerHeight.current + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = miniH), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.listen_together_title), + style = MaterialTheme.typography.titleLarge + ) + Text( + text = stringResource(R.string.listen_together_debug_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ListenTogetherRoomPanel(showBaseUrlEditor = true) + } +} @Composable fun ListenTogetherRoomPanel( @@ -89,11 +152,15 @@ fun ListenTogetherRoomPanel( var autoPauseOnMemberChange by rememberSaveable { mutableStateOf(true) } var shareAudioLinks by rememberSaveable { mutableStateOf(true) } var runningActionResId by remember { mutableStateOf(null) } + var showSessionDetails by rememberSaveable { mutableStateOf(false) } + var showTrackPayload by rememberSaveable { mutableStateOf(false) } + var showMemberDetails by rememberSaveable { mutableStateOf(false) } val isInRoom = !sessionState.roomId.isNullOrBlank() val role = resolveListenTogetherRole(sessionState.userUuid, sessionState.role, roomState) val isController = role == "controller" val effectiveBaseUrl = resolveListenTogetherBaseUrl(baseUrl) + val tokenPreview = remember(sessionState.token) { sessionState.token.maskedTokenPreview() } val roomSettings = roomState?.settings ?: ListenTogetherRoomSettings( allowMemberControl = allowMemberControl, autoPauseOnMemberChange = autoPauseOnMemberChange, @@ -105,23 +172,17 @@ fun ListenTogetherRoomPanel( } } - LaunchedEffect(savedBaseUrl) { if (baseUrl.isBlank()) baseUrl = resolveListenTogetherBaseUrl(savedBaseUrl) } + LaunchedEffect(savedBaseUrl) { + if (baseUrl.isBlank()) baseUrl = resolveListenTogetherBaseUrl(savedBaseUrl) + } LaunchedEffect(savedUserUuid) { if (userUuid.isBlank()) { - userUuid = if (savedUserUuid.isBlank()) { - preferences.getOrCreateUserUuid() - } else { - savedUserUuid - } + userUuid = if (savedUserUuid.isBlank()) preferences.getOrCreateUserUuid() else savedUserUuid } } LaunchedEffect(savedNickname) { if (nickname.isBlank()) { - nickname = if (savedNickname.isBlank()) { - preferences.getOrCreateNickname() - } else { - savedNickname - } + nickname = if (savedNickname.isBlank()) preferences.getOrCreateNickname() else savedNickname } } LaunchedEffect(sessionState.roomId) { sessionState.roomId?.let { roomIdInput = it } } @@ -139,91 +200,136 @@ fun ListenTogetherRoomPanel( shareAudioLinks = roomSettings.shareAudioLinks } } - Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f))) { + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) + ) + ) { Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp), + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + DebugHeader( + connectionState = sessionState.connectionState, + role = role, + roomStatus = roomState?.roomStatus, + roomVersion = roomState?.version, + roomId = sessionState.roomId + ) + if (showBaseUrlEditor) { OutlinedTextField( value = baseUrl, onValueChange = { baseUrl = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.listen_together_worker_base_url)) }, singleLine = true ) } - OutlinedTextField( - value = nickname, - onValueChange = { nickname = it.trim().take(24) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - label = { Text(stringResource(R.string.listen_together_nickname)) }, - singleLine = true - ) - OutlinedTextField( - value = roomIdInput, - onValueChange = { roomIdInput = normalizeListenTogetherRoomId(it).take(6) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - label = { Text(stringResource(R.string.listen_together_room_id)) }, - singleLine = true, - readOnly = isInRoom - ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it.trim().take(24) }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.listen_together_nickname)) }, + singleLine = true + ) + OutlinedTextField( + value = roomIdInput, + onValueChange = { roomIdInput = normalizeListenTogetherRoomId(it).take(6) }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.listen_together_room_id)) }, + singleLine = true, + readOnly = isInRoom + ) + } + runningActionResId?.let { resId -> Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Text(stringResource(resId), style = MaterialTheme.typography.bodySmall) } } + validateListenTogetherNickname(nickname)?.let { ErrorText(it) } - if (!isInRoom) validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { ErrorText(it) } - if (!isInRoom) RoomActions( - runningActionResId = runningActionResId, - currentQueue = currentQueue, - currentSong = currentSong, - isPlaying = isPlaying, - positionMs = positionMs, + if (!isInRoom) { + validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { ErrorText(it) } + } + + QuickActionSection( activity = activity, - userUuid = userUuid, - nickname = nickname, - roomIdInput = roomIdInput, - effectiveBaseUrl = effectiveBaseUrl, - roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), sessionState = sessionState, - preferences = preferences, - sessionManager = sessionManager, + effectiveBaseUrl = effectiveBaseUrl, + clipboard = clipboard, onRunningActionChange = { runningActionResId = it } ) - if (isInRoom) ConnectedActions(runningActionResId, effectiveBaseUrl, nickname, roomIdInput, sessionState, sessionManager, preferences, activity) { runningActionResId = it } + + if (!isInRoom) { + RoomActions( + runningActionResId = runningActionResId, + currentQueue = currentQueue, + currentSong = currentSong, + isPlaying = isPlaying, + positionMs = positionMs, + activity = activity, + userUuid = userUuid, + nickname = nickname, + roomIdInput = roomIdInput, + effectiveBaseUrl = effectiveBaseUrl, + roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + sessionState = sessionState, + preferences = preferences, + sessionManager = sessionManager, + onRunningActionChange = { runningActionResId = it } + ) + } else { + ConnectedActions( + runningActionResId = runningActionResId, + effectiveBaseUrl = effectiveBaseUrl, + nickname = nickname, + roomIdInput = roomIdInput, + sessionState = sessionState, + sessionManager = sessionManager, + preferences = preferences, + activity = activity, + onRunningActionChange = { runningActionResId = it } + ) + } + if (isController) { TextButton( onClick = { val roomId = sessionState.roomId ?: return@TextButton val inviteText = buildString { append(context.getString(R.string.listen_together_invite_share_text, sessionState.nickname ?: context.getString(R.string.listen_together_title), roomId)) - inviteUri?.let { append("\n"); append(it) } + inviteUri?.let { + append("\n") + append(it) + } } clipboard.setText(AnnotatedString(inviteText)) Toast.makeText(context, context.getString(R.string.listen_together_invite_copied), Toast.LENGTH_SHORT).show() }, - enabled = !sessionState.roomId.isNullOrBlank(), - modifier = Modifier.padding(horizontal = 12.dp) - ) { Text(stringResource(R.string.listen_together_copy_invite)) } + enabled = !sessionState.roomId.isNullOrBlank() + ) { + Text(stringResource(R.string.listen_together_copy_invite)) + } } + if (isController || !isInRoom) { - HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + HorizontalDivider() SettingsSection( settings = if (isInRoom) roomSettings else ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), enabled = runningActionResId == null && (!isInRoom || isController), @@ -236,15 +342,137 @@ fun ListenTogetherRoomPanel( persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, updated) if (isInRoom && isController) { val result = sessionManager.updateRoomSettings(updated) - check(result.ok) { result.error ?: "websocket unavailable" } + check(result.ok) { + result.error ?: context.getString(R.string.listen_together_debug_ws_unavailable) + } } - }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() + } } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() } ) } - StatusSection(sessionState, roomState, role, currentSong?.name, isPlaying) - MemberSection(roomState?.members?.sortedBy { it.joinedAt }.orEmpty()) + + HorizontalDivider() + StatusSection( + sessionState = sessionState, + roomState = roomState, + role = role, + fallbackTrackName = currentSong?.name, + isPlaying = isPlaying, + effectiveBaseUrl = effectiveBaseUrl, + tokenPreview = tokenPreview, + expanded = showSessionDetails, + onToggleExpanded = { showSessionDetails = !showSessionDetails } + ) + TrackDebugSection( + track = roomState?.track, + fallbackTrackName = currentSong?.name, + expanded = showTrackPayload, + onToggleExpanded = { showTrackPayload = !showTrackPayload } + ) + MemberSection( + members = roomState?.members?.sortedBy { it.joinedAt }.orEmpty(), + expanded = showMemberDetails, + onToggleExpanded = { showMemberDetails = !showMemberDetails } + ) + } + } +} + +@Composable +private fun DebugHeader( + connectionState: ListenTogetherConnectionState, + role: String?, + roomStatus: String?, + roomVersion: Long?, + roomId: String? +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + DebugChip(stringResource(connectionState.labelResId())) + DebugChip(stringResource(roleLabelResId(role))) + DebugChip(stringResource(roomStatusLabelResId(roomStatus))) + roomId?.takeIf { it.isNotBlank() }?.let { DebugChip("#$it") } + DebugChip("v${roomVersion ?: -1}") + } +} + +@Composable +private fun QuickActionSection( + activity: ComponentActivity?, + sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, + effectiveBaseUrl: String, + clipboard: ClipboardManager, + onRunningActionChange: (Int?) -> Unit +) { + val context = LocalContext.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + activity?.lifecycleScope?.launch { + onRunningActionChange(R.string.settings_listen_together_server_testing) + runCatching { + val result = AppContainer.listenTogetherApi.testServerAvailability(effectiveBaseUrl) + val detail = if (result.ok) { + context.getString(R.string.listen_together_debug_probe_reachable) + } else { + result.message + } + Toast.makeText(context, context.getString(R.string.listen_together_debug_probe_result, detail), Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() + } + onRunningActionChange(null) + } + } + ) { + Icon(Icons.Outlined.NetworkCheck, contentDescription = null) + Text(stringResource(R.string.listen_together_debug_probe)) + } + OutlinedButton( + onClick = { + val sent = AppContainer.listenTogetherSessionManager.sendPing() + Toast.makeText(context, context.getString(if (sent) R.string.listen_together_debug_ping_sent else R.string.listen_together_debug_ping_failed), Toast.LENGTH_SHORT).show() + }, + enabled = sessionState.connectionState == ListenTogetherConnectionState.CONNECTED + ) { + Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) + Text(stringResource(R.string.listen_together_debug_ping)) + } + OutlinedButton( + onClick = { AppContainer.listenTogetherSessionManager.connectWebSocket() }, + enabled = !sessionState.wsUrl.isNullOrBlank() + ) { + Icon(Icons.Outlined.Refresh, contentDescription = null) + Text(stringResource(R.string.listen_together_debug_reconnect_ws)) + } + OutlinedButton( + onClick = { AppContainer.listenTogetherSessionManager.disconnectWebSocket() }, + enabled = sessionState.connectionState != ListenTogetherConnectionState.DISCONNECTED + ) { + Icon(Icons.Outlined.StopCircle, contentDescription = null) + Text(stringResource(R.string.listen_together_debug_disconnect_ws)) + } + OutlinedButton( + onClick = { + sessionState.wsUrl?.let { + clipboard.setText(AnnotatedString(it)) + Toast.makeText(context, context.getString(R.string.listen_together_debug_ws_url_copied), Toast.LENGTH_SHORT).show() + } + }, + enabled = !sessionState.wsUrl.isNullOrBlank() + ) { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + Text(stringResource(R.string.listen_together_debug_copy_ws_url)) } } } @@ -252,8 +480,8 @@ fun ListenTogetherRoomPanel( @Composable private fun RoomActions( runningActionResId: Int?, - currentQueue: List, - currentSong: moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem?, + currentQueue: List, + currentSong: SongItem?, isPlaying: Boolean, positionMs: Long, activity: ComponentActivity?, @@ -271,45 +499,72 @@ private fun RoomActions( val userUuidError = validateListenTogetherUserUuid(userUuid) val nicknameError = validateListenTogetherNickname(nickname) val roomIdError = validateListenTogetherRoomId(roomIdInput) + Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Button(onClick = { - activity?.lifecycleScope?.launch { - onRunningActionChange(R.string.listen_together_creating_room) - runCatching { - persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) - sessionManager.createRoom(effectiveBaseUrl, userUuid, nickname, currentQueue, currentQueue.indexOfFirst { it == currentSong }.takeIf { it >= 0 } ?: 0, positionMs, isPlaying, roomSettings) - sessionManager.connectWebSocket() - }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } - onRunningActionChange(null) - } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() - }, enabled = runningActionResId == null && currentQueue.isNotEmpty() && userUuidError == null && nicknameError == null, modifier = Modifier.weight(1f)) { - Icon(Icons.Outlined.PlayArrow, contentDescription = null); Text(stringResource(R.string.listen_together_create_and_connect)) + Button( + onClick = { + activity?.lifecycleScope?.launch { + onRunningActionChange(R.string.listen_together_creating_room) + runCatching { + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) + sessionManager.createRoom( + effectiveBaseUrl, + userUuid, + nickname, + currentQueue, + currentQueue.indexOfFirst { it == currentSong }.takeIf { it >= 0 } ?: 0, + positionMs, + isPlaying, + roomSettings + ) + sessionManager.connectWebSocket() + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() + } + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, + enabled = runningActionResId == null && + currentQueue.isNotEmpty() && + userUuidError == null && + nicknameError == null, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Outlined.PlayArrow, contentDescription = null) + Text(stringResource(R.string.listen_together_create_and_connect)) } - Button(onClick = { - activity?.lifecycleScope?.launch { - onRunningActionChange(R.string.listen_together_joining_room) - runCatching { - val targetRoomId = normalizeListenTogetherRoomId(roomIdInput) - val currentRoomId = sessionState.roomId?.let(::normalizeListenTogetherRoomId) - if (currentRoomId != null && currentRoomId == targetRoomId) { - Toast.makeText(context, context.getString(R.string.listen_together_same_room_join_ignored, targetRoomId), Toast.LENGTH_SHORT).show() - return@runCatching + Button( + onClick = { + activity?.lifecycleScope?.launch { + onRunningActionChange(R.string.listen_together_joining_room) + runCatching { + val targetRoomId = normalizeListenTogetherRoomId(roomIdInput) + val currentRoomId = sessionState.roomId?.let(::normalizeListenTogetherRoomId) + if (currentRoomId != null && currentRoomId == targetRoomId) { + Toast.makeText(context, context.getString(R.string.listen_together_same_room_join_ignored, targetRoomId), Toast.LENGTH_SHORT).show() + return@runCatching + } + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) + sessionManager.joinRoom(effectiveBaseUrl, targetRoomId, userUuid, nickname) + sessionManager.connectWebSocket() + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } - persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, roomSettings) - PlayerManager.resetForListenTogetherJoin() - sessionManager.leaveRoom() - sessionManager.joinRoom(effectiveBaseUrl, targetRoomId, userUuid, nickname) - sessionManager.connectWebSocket() - }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } - onRunningActionChange(null) - } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() - }, enabled = runningActionResId == null && roomIdInput.isNotBlank() && userUuidError == null && nicknameError == null && roomIdError == null, modifier = Modifier.weight(1f)) { - Icon(Icons.Outlined.Link, contentDescription = null); Text(stringResource(R.string.listen_together_join_and_connect)) + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, + enabled = runningActionResId == null && + roomIdInput.isNotBlank() && + userUuidError == null && + nicknameError == null && + roomIdError == null, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Outlined.Link, contentDescription = null) + Text(stringResource(R.string.listen_together_join_and_connect)) } } } @@ -328,27 +583,37 @@ private fun ConnectedActions( ) { val context = LocalContext.current Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Button(onClick = { - activity?.lifecycleScope?.launch { - val roomId = sessionState.roomId ?: roomIdInput - if (roomId.isBlank()) return@launch - onRunningActionChange(R.string.listen_together_refreshing_room_state) - runCatching { - preferences.setNickname(nickname) - sessionManager.refreshRoomState(effectiveBaseUrl, roomId) - }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } - onRunningActionChange(null) - } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() - }, enabled = runningActionResId == null, modifier = Modifier.weight(1f)) { - Icon(Icons.Outlined.Refresh, contentDescription = null); Text(stringResource(R.string.action_refresh)) + Button( + onClick = { + activity?.lifecycleScope?.launch { + val roomId = sessionState.roomId ?: roomIdInput + if (roomId.isBlank()) return@launch + onRunningActionChange(R.string.listen_together_refreshing_room_state) + runCatching { + preferences.setNickname(nickname) + sessionManager.refreshRoomState(effectiveBaseUrl, roomId) + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() + } + onRunningActionChange(null) + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + }, + enabled = runningActionResId == null, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Outlined.Refresh, contentDescription = null) + Text(stringResource(R.string.action_refresh)) } - Button(onClick = { sessionManager.leaveRoom() }, enabled = sessionState.connectionState != ListenTogetherConnectionState.CONNECTING, modifier = Modifier.weight(1f)) { - Icon(Icons.Outlined.StopCircle, contentDescription = null); Text(stringResource(R.string.listen_together_leave_room)) + Button( + onClick = { sessionManager.leaveRoom() }, + enabled = sessionState.connectionState != ListenTogetherConnectionState.CONNECTING, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Outlined.StopCircle, contentDescription = null) + Text(stringResource(R.string.listen_together_leave_room)) } } } @@ -360,27 +625,49 @@ private fun Context.findComponentActivity(): ComponentActivity? = when (this) { } @Composable -private fun SettingsSection(settings: ListenTogetherRoomSettings, enabled: Boolean, onSettingsChange: (ListenTogetherRoomSettings) -> Unit) { +private fun SettingsSection( + settings: ListenTogetherRoomSettings, + enabled: Boolean, + onSettingsChange: (ListenTogetherRoomSettings) -> Unit +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text(stringResource(R.string.listen_together_settings_title), style = MaterialTheme.typography.titleSmall) - SettingToggleRow(stringResource(R.string.listen_together_setting_member_control_title), stringResource(R.string.listen_together_setting_member_control_desc), settings.allowMemberControl, enabled) { onSettingsChange(settings.copy(allowMemberControl = it)) } - SettingToggleRow(stringResource(R.string.listen_together_setting_auto_pause_title), stringResource(R.string.listen_together_setting_auto_pause_desc), settings.autoPauseOnMemberChange, enabled) { onSettingsChange(settings.copy(autoPauseOnMemberChange = it)) } - SettingToggleRow(stringResource(R.string.listen_together_setting_share_audio_links_title), stringResource(R.string.listen_together_setting_share_audio_links_desc), settings.shareAudioLinks, enabled) { onSettingsChange(settings.copy(shareAudioLinks = it)) } + SettingToggleRow( + stringResource(R.string.listen_together_setting_member_control_title), + stringResource(R.string.listen_together_setting_member_control_desc), + settings.allowMemberControl, + enabled + ) { onSettingsChange(settings.copy(allowMemberControl = it)) } + SettingToggleRow( + stringResource(R.string.listen_together_setting_auto_pause_title), + stringResource(R.string.listen_together_setting_auto_pause_desc), + settings.autoPauseOnMemberChange, + enabled + ) { onSettingsChange(settings.copy(autoPauseOnMemberChange = it)) } + SettingToggleRow( + stringResource(R.string.listen_together_setting_share_audio_links_title), + stringResource(R.string.listen_together_setting_share_audio_links_desc), + settings.shareAudioLinks, + enabled + ) { onSettingsChange(settings.copy(shareAudioLinks = it)) } } } @Composable -private fun SettingToggleRow(title: String, subtitle: String, checked: Boolean, enabled: Boolean, onCheckedChange: (Boolean) -> Unit) { +private fun SettingToggleRow( + title: String, + subtitle: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(title, style = MaterialTheme.typography.bodyMedium) @@ -393,55 +680,308 @@ private fun SettingToggleRow(title: String, subtitle: String, checked: Boolean, @Composable private fun StatusSection( sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, - roomState: moe.ouom.neriplayer.listentogether.ListenTogetherRoomState?, + roomState: ListenTogetherRoomState?, role: String?, fallbackTrackName: String?, - isPlaying: Boolean + isPlaying: Boolean, + effectiveBaseUrl: String, + tokenPreview: String, + expanded: Boolean, + onToggleExpanded: () -> Unit +) { + val context = LocalContext.current + val playbackState = if ((roomState?.playback?.state ?: if (isPlaying) "playing" else "paused") == "playing") { + stringResource(R.string.listen_together_playback_playing) + } else { + stringResource(R.string.listen_together_playback_paused) + } + val summaryFields = listOf( + DebugField(stringResource(R.string.listen_together_connection), stringResource(sessionState.connectionState.labelResId())), + DebugField(stringResource(R.string.listen_together_role), stringResource(roleLabelResId(role))), + DebugField(stringResource(R.string.listen_together_room_status), stringResource(roomStatusLabelResId(roomState?.roomStatus))), + DebugField(stringResource(R.string.listen_together_room_id), sessionState.roomId ?: "-"), + DebugField(stringResource(R.string.listen_together_version), roomState?.version?.toString() ?: "-"), + DebugField(stringResource(R.string.listen_together_members), roomState?.members?.size?.toString() ?: "0"), + DebugField(stringResource(R.string.listen_together_queue_size), roomState?.queue?.size?.toString() ?: "0"), + DebugField(stringResource(R.string.listen_together_playback), playbackState) + ) + val detailFields = buildList { + addAll(summaryFields) + add(DebugField(stringResource(R.string.listen_together_track), roomState?.track?.name ?: fallbackTrackName ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_base_url), effectiveBaseUrl)) + add(DebugField(stringResource(R.string.listen_together_debug_ws_url), sessionState.wsUrl ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_token), tokenPreview)) + add(DebugField(stringResource(R.string.listen_together_user_uuid), sessionState.userUuid ?: "-")) + add(DebugField(stringResource(R.string.listen_together_nickname), sessionState.nickname ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_schema), roomState?.schemaVersion?.toString() ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_expected_position), sessionState.expectedPositionMs?.let(::formatDurationDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_playback_base_position), roomState?.playback?.basePositionMs?.let(::formatDurationDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_playback_base_time), roomState?.playback?.baseTimestampMs?.let(::formatEpochDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_playback_rate), roomState?.playback?.playbackRate?.toString() ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_controller_uuid), roomState?.controllerUserUuid ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_controller_user_id), roomState?.controllerUserId ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_controller_heartbeat), roomState?.controllerHeartbeatAt?.let(::formatEpochDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_controller_offline_since), roomState?.controllerOfflineSince?.let(::formatEpochDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_updated_at), roomState?.updatedAt?.let(::formatEpochDebug) ?: "-")) + add(DebugField(stringResource(R.string.listen_together_debug_closed_reason), roomState?.closedReason ?: "-")) + sessionState.lastError?.takeIf { it.isNotBlank() }?.let { add(DebugField(stringResource(R.string.listen_together_last_error), it)) } + sessionState.roomNotice?.takeIf { it.isNotBlank() }?.let { add(DebugField(stringResource(R.string.listen_together_debug_raw_notice), it)) } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DebugSectionHeader( + title = stringResource(R.string.listen_together_debug_session_title), + expanded = expanded, + onToggleExpanded = onToggleExpanded + ) + DebugFieldGrid(summaryFields) + sessionState.lastError?.takeIf { it.isNotBlank() }?.let { + DebugBanner(stringResource(R.string.listen_together_last_error), it, highlighted = true) + } + sessionState.roomNotice?.takeIf { it.isNotBlank() && !it.startsWith("member_joined:") && !it.startsWith("member_left:") }?.let { + DebugBanner(stringResource(R.string.listen_together_notice), it.toDisplayNotice(context)) + } + if (expanded) { + DebugFieldGrid(detailFields) + } + } +} + +@Composable +private fun TrackDebugSection( + track: ListenTogetherTrack?, + fallbackTrackName: String?, + expanded: Boolean, + onToggleExpanded: () -> Unit ) { + val summaryFields = listOf( + DebugField(stringResource(R.string.listen_together_debug_track_name), track?.name ?: fallbackTrackName ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_channel), track?.channelId ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_duration), track?.durationMs?.let(::formatDurationDebug) ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_stable_key), track?.stableKey ?: "-") + ) + val detailFields = listOf( + DebugField(stringResource(R.string.listen_together_debug_track_name), track?.name ?: fallbackTrackName ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_stable_key), track?.stableKey ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_channel), track?.channelId ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_audio_id), track?.audioId ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_sub_audio_id), track?.subAudioId ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_playlist_context), track?.playlistContextId ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_media_uri), track?.mediaUri ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_stream_url), track?.streamUrl ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_duration), track?.durationMs?.let(::formatDurationDebug) ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_cover), track?.coverUrl ?: "-") + ) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DebugSectionHeader( + title = stringResource(R.string.listen_together_debug_track_payload_title), + expanded = expanded, + onToggleExpanded = onToggleExpanded + ) + DebugFieldGrid(summaryFields) + if (expanded) { + DebugFieldGrid(detailFields) + } + } +} + +@Composable +private fun MemberSection( + members: List, + expanded: Boolean, + onToggleExpanded: () -> Unit +) { + if (members.isEmpty()) return + + val roleCounts = members.groupingBy { it.role.ifBlank { "unknown" } }.eachCount() + val summaryFields = buildList { + add(DebugField(stringResource(R.string.listen_together_members), members.size.toString())) + roleCounts.forEach { (role, count) -> + val label = when (role) { + "controller" -> stringResource(R.string.listen_together_role_controller) + "listener" -> stringResource(R.string.listen_together_role_listener) + else -> stringResource(R.string.listen_together_role_none) + } + add(DebugField(label, count.toString())) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DebugSectionHeader( + title = stringResource(R.string.listen_together_member_list_title), + expanded = expanded, + onToggleExpanded = onToggleExpanded, + suffix = members.size.toString() + ) + DebugFieldGrid(summaryFields) + if (expanded) { + members.forEach { MemberCard(it) } + } + } +} + +@Composable +private fun MemberCard(member: ListenTogetherMember) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = member.nickname.ifBlank { member.userUuid.ifBlank { member.userId.orEmpty() } }, + style = MaterialTheme.typography.bodyMedium + ) + DebugFieldGrid( + listOf( + DebugField(stringResource(R.string.listen_together_debug_member_role), member.role.ifBlank { "-" }), + DebugField(stringResource(R.string.listen_together_debug_member_user_id), member.userId.orEmpty().ifBlank { "-" }), + DebugField(stringResource(R.string.listen_together_debug_controller_uuid), member.userUuid.ifBlank { "-" }), + DebugField(stringResource(R.string.listen_together_debug_member_joined), formatEpochDebug(member.joinedAt)) + ) + ) + } + } +} + +@Composable +private fun DebugFieldGrid(fields: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + fields.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + row.forEach { field -> + DebugFieldCard(field = field, modifier = Modifier.weight(1f)) + } + if (row.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun DebugFieldCard( + field: DebugField, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.24f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = field.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = field.value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun DebugBanner( + label: String, + value: String, + highlighted: Boolean = false +) { + val bg = if (highlighted) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + val fg = if (highlighted) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + .background(bg, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - DebugValueRow(stringResource(R.string.listen_together_connection), stringResource(sessionState.connectionState.labelResId())) - DebugValueRow(stringResource(R.string.listen_together_role), stringResource(roleLabelResId(role))) - DebugValueRow(stringResource(R.string.listen_together_room_status), stringResource(roomStatusLabelResId(roomState?.roomStatus))) - DebugValueRow(stringResource(R.string.listen_together_room_id), sessionState.roomId ?: "-") - DebugValueRow(stringResource(R.string.listen_together_version), roomState?.version?.toString() ?: "-") - DebugValueRow(stringResource(R.string.listen_together_members), roomState?.members?.size?.toString() ?: "0") - DebugValueRow(stringResource(R.string.listen_together_queue_size), roomState?.queue?.size?.toString() ?: "0") - DebugValueRow(stringResource(R.string.listen_together_track), roomState?.track?.name ?: fallbackTrackName ?: "-") - DebugValueRow(stringResource(R.string.listen_together_playback), stringResource(if ((roomState?.playback?.state ?: if (isPlaying) "playing" else "paused") == "playing") R.string.listen_together_playback_playing else R.string.listen_together_playback_paused)) - sessionState.lastError?.takeIf { it.isNotBlank() }?.let { DebugValueRow(stringResource(R.string.listen_together_last_error), it) } - sessionState.roomNotice?.takeIf { it.isNotBlank() && !it.startsWith("member_joined:") && !it.startsWith("member_left:") }?.let { DebugValueRow(stringResource(R.string.listen_together_notice), it.toDisplayNotice(LocalContext.current)) } + Text(text = label, style = MaterialTheme.typography.labelSmall, color = fg) + Text(text = value, style = MaterialTheme.typography.bodySmall, color = fg) } } @Composable -private fun MemberSection(members: List) { - if (members.isEmpty()) return - HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) - Column( +private fun DebugChip(text: String) { + AssistChip( + onClick = {}, + label = { Text(text) }, + enabled = false, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) +} + +@Composable +private fun ErrorText(message: String) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) +} + +@Composable +fun ListenTogetherDebugPanel(modifier: Modifier = Modifier) { + ListenTogetherRoomPanel(modifier = modifier, showBaseUrlEditor = true) +} + +@Composable +private fun DebugSectionHeader( + title: String, + expanded: Boolean, + onToggleExpanded: () -> Unit, + suffix: String? = null +) { + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + .clickable(onClick = onToggleExpanded) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text(stringResource(R.string.listen_together_member_list_title), style = MaterialTheme.typography.titleSmall) - members.forEach { member -> - DebugValueRow( - member.nickname.ifBlank { member.userUuid.ifBlank { member.userId.orEmpty() } }, - stringResource(roleLabelResId(member.role)) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleSmall) + suffix?.let { + Text(text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + } } + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null + ) } } -@Composable private fun ErrorText(message: String) { Text(text = message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 20.dp)) } -@Composable private fun DebugValueRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary); Text(value, style = MaterialTheme.typography.bodySmall) } } -@Composable fun ListenTogetherDebugPanel(modifier: Modifier = Modifier) { ListenTogetherRoomPanel(modifier, false) } - private suspend fun persistSettings( preferences: ListenTogetherPreferences, baseUrl: String, @@ -469,11 +1009,19 @@ private fun roleLabelResId(role: String?): Int = when (role) { else -> R.string.listen_together_role_none } -private fun resolveListenTogetherRole(userUuid: String?, fallbackRole: String?, roomState: moe.ouom.neriplayer.listentogether.ListenTogetherRoomState?): String? { +private fun resolveListenTogetherRole( + userUuid: String?, + fallbackRole: String?, + roomState: ListenTogetherRoomState? +): String? { val sessionUserId = userUuid?.trim()?.takeIf { it.isNotBlank() } val controllerUserId = roomState?.controllerUserUuid?.trim()?.takeIf { it.isNotBlank() } ?: roomState?.controllerUserId?.trim()?.takeIf { it.isNotBlank() } - return if (sessionUserId != null && controllerUserId != null) if (sessionUserId == controllerUserId) "controller" else "listener" else fallbackRole + return if (sessionUserId != null && controllerUserId != null) { + if (sessionUserId == controllerUserId) "controller" else "listener" + } else { + fallbackRole + } } private fun roomStatusLabelResId(status: String?): Int = when (status) { @@ -482,12 +1030,40 @@ private fun roomStatusLabelResId(status: String?): Int = when (status) { else -> R.string.listen_together_room_status_active } -private fun String.toDisplayNotice(context: android.content.Context): String = when { - startsWith("controller_offline:") -> context.getString(R.string.listen_together_notice_controller_offline, substringAfter(':').toLongOrNull() ?: 10L) - startsWith("member_joined:") -> context.getString(R.string.listen_together_notice_member_joined, substringAfter(':')) - startsWith("member_left:") -> context.getString(R.string.listen_together_notice_member_left, substringAfter(':')) - this == "controller_reconnected" -> context.getString(R.string.listen_together_notice_controller_reconnected) - this == "controller_timeout" || this == "room_closed" -> context.getString(R.string.listen_together_notice_room_closed) - this == "controller_offline" -> context.getString(R.string.listen_together_notice_controller_offline, 10L) - else -> this +private fun String.toDisplayNotice(context: Context): String = + when { + startsWith("controller_offline:") -> context.getString(R.string.listen_together_notice_controller_offline, substringAfter(':').toLongOrNull() ?: 10L) + startsWith("member_joined:") -> context.getString(R.string.listen_together_notice_member_joined, substringAfter(':')) + startsWith("member_left:") -> context.getString(R.string.listen_together_notice_member_left, substringAfter(':')) + this == "controller_reconnected" -> context.getString(R.string.listen_together_notice_controller_reconnected) + this == "controller_timeout" || this == "room_closed" || contains("room closed", ignoreCase = true) -> + context.getString(R.string.listen_together_notice_room_closed) + contains("unauthorized", ignoreCase = true) || contains("http=401", ignoreCase = true) || contains("(401)", ignoreCase = true) -> + context.getString(R.string.listen_together_error_unauthorized) + contains("room not initialized", ignoreCase = true) || contains("not found in do", ignoreCase = true) -> + context.getString(R.string.listen_together_error_room_not_found) + contains("controller offline", ignoreCase = true) -> + context.getString(R.string.listen_together_error_controller_offline) + contains("member control disabled", ignoreCase = true) -> + context.getString(R.string.listen_together_error_member_control_disabled) + else -> this + } + +private fun String?.maskedTokenPreview(): String { + if (this.isNullOrBlank()) return "-" + if (length <= 10) return this + return "${take(6)}...${takeLast(4)}" +} + +private fun formatEpochDebug(value: Long): String { + return runCatching { + "${SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()).format(Date(value))} ($value)" + }.getOrDefault(value.toString()) +} + +private fun formatDurationDebug(value: Long): String { + val totalSeconds = value / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d (%d ms)".format(minutes, seconds, value) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 3aaa0e32..79f760c6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1492,6 +1492,53 @@ The controller disabled member playback control. The controller is offline. Try again after reconnection. Local playback is disabled while Listen Together is active. + Room credentials expired. Please join Listen Together again. + The room no longer exists or is unavailable. + Listen Together connection was interrupted. Reconnecting now. + Listen Together session expired. Rejoining the room now. + Probe + Server probe: %1$s + Inspect protocol state, room data, and WebSocket debug actions + Used to debug Listen Together demo Worker room state, WebSocket connectivity, and sync payloads. + Server reachable + WebSocket unavailable + Ping + Ping sent + Ping failed + Reconnect WS + Disconnect WS + Copy WS URL + WebSocket URL copied + Debug Session + Track Payload + Base URL + Resolved WS URL + Token + Schema + Expected Position + Playback Base Position + Playback Base Time + Playback Rate + Controller UUID + Controller UserId + Controller Heartbeat + Controller Offline Since + Updated At + Closed Reason + Raw Notice + Stable Key + Track Name + Channel + Audio Id + Sub Audio Id + Playlist Context + Media URI + Stream URL + Duration + Cover + Member Role + Member UserId + Joined At Joining Listen Together Syncing Listen Together state Listen Together active diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3188e6d..00cda461 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1488,6 +1488,53 @@ 房主已关闭听众操作权限。 房主当前离线,请等待重连后再试。 处于一起听时不可切换到本地音乐播放。 + 房间凭证已失效,请重新加入一起听。 + 房间不存在或已失效,无法继续加入。 + 一起听连接暂时中断,正在重连。 + 一起听连接已失效,正在重新加入房间。 + 探测服务 + 服务探测结果:%1$s + 查看协议状态、房间数据与 WebSocket 调试操作 + 用于调试一起听 demo Worker 的房间状态、WebSocket 连接和同步数据。 + 服务可达 + WebSocket 不可用 + 发送 Ping + Ping 已发送 + Ping 发送失败 + 重连 WS + 断开 WS + 复制 WS 地址 + WebSocket 地址已复制 + 调试会话 + 轨道载荷 + 服务地址 + WebSocket 地址 + 房间令牌 + 协议版本 + 期望位置 + 播放基准位置 + 播放基准时间 + 播放倍率 + 房主 UUID + 房主 UserId + 房主心跳时间 + 房主离线起点 + 房间更新时间 + 关闭原因 + 原始提示 + 稳定键 + 曲目名 + 来源频道 + 音频 ID + 子音频 ID + 播放上下文 + 媒体 URI + 直链地址 + 时长 + 封面 + 成员角色 + 成员 UserId + 加入时间 正在加入一起听 正在同步一起听状态 当前处于一起听 From 23e9a0458b313b6c281c2e20a281d0748129d6d7 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Fri, 27 Mar 2026 16:37:38 +0800 Subject: [PATCH 03/11] feat(sync): add WebDAV backup sync --- .../ouom/neriplayer/activity/MainActivity.kt | 9 + .../data/history/PlayHistoryRepository.kt | 3 +- .../local/playlist/LocalPlaylistRepository.kt | 3 +- .../favorite/FavoritePlaylistRepository.kt | 5 +- .../data/sync/webdav/WebDavApiClient.kt | 165 +++ .../data/sync/webdav/WebDavStorage.kt | 92 ++ .../data/sync/webdav/WebDavSyncManager.kt | 964 ++++++++++++++++++ .../data/sync/webdav/WebDavSyncWorker.kt | 155 +++ .../ui/screen/tab/SettingsScreen.kt | 14 +- .../component/SettingsBackupRestoreSection.kt | 180 +++- .../settings/dialog/SettingsWebDavDialogs.kt | 175 ++++ .../ui/viewmodel/WebDavSyncViewModel.kt | 178 ++++ app/src/main/res/values-en/strings.xml | 27 + app/src/main/res/values/strings.xml | 27 + 14 files changed, 1968 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavApiClient.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavStorage.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncManager.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncWorker.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavDialogs.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncViewModel.kt diff --git a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt index d80b60d8..9e43b158 100644 --- a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt +++ b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt @@ -113,6 +113,8 @@ import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager import moe.ouom.neriplayer.data.settings.SettingsRepository import moe.ouom.neriplayer.data.settings.readThemePreferenceSnapshotSync +import moe.ouom.neriplayer.data.sync.webdav.WebDavStorage +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncWorker import moe.ouom.neriplayer.listentogether.DEFAULT_LISTEN_TOGETHER_BASE_URL import moe.ouom.neriplayer.listentogether.ListenTogetherInvite import moe.ouom.neriplayer.listentogether.normalizeListenTogetherRoomId @@ -209,6 +211,13 @@ class MainActivity : ComponentActivity() { markMutation = false ) } + val webDavStorage = WebDavStorage(this@MainActivity) + if (webDavStorage.isConfigured()) { + WebDavSyncWorker.scheduleDelayedSync( + this@MainActivity, + markMutation = false + ) + } } NeriTheme(useDark = useDark, useDynamic = dynamicColor) { diff --git a/app/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt b/app/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt index c279a8d9..aa994fc2 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt @@ -38,6 +38,7 @@ import moe.ouom.neriplayer.data.model.SongIdentity import moe.ouom.neriplayer.data.sync.github.GitHubSyncWorker import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage import moe.ouom.neriplayer.data.sync.github.SyncRecentPlayDeletion +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncWorker import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.util.NPLogger import java.io.File @@ -121,9 +122,9 @@ class PlayHistoryRepository private constructor(private val app: Context) { storage.markSyncMutation() if (!storage.isAutoSyncEnabled()) { NPLogger.d("PlayHistoryRepo", "Auto sync is disabled, skipping sync") - return } GitHubSyncWorker.scheduleDelayedSync(app, triggerByUserAction = false) + WebDavSyncWorker.scheduleDelayedSync(app, triggerByUserAction = false) NPLogger.d("PlayHistoryRepo", "Sync scheduled after play history change") } catch (e: Exception) { NPLogger.e("PlayHistoryRepo", "Failed to trigger sync", e) diff --git a/app/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt b/app/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt index 0175ccd6..157540e7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt @@ -46,6 +46,7 @@ import moe.ouom.neriplayer.data.model.sameIdentityAs import moe.ouom.neriplayer.data.sync.github.CoverUrlMapper import moe.ouom.neriplayer.data.sync.github.GitHubSyncWorker import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncWorker import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.util.NPLogger import org.json.JSONObject @@ -154,9 +155,9 @@ class LocalPlaylistRepository private constructor(private val context: Context) storage.markSyncMutation() if (!storage.isAutoSyncEnabled()) { NPLogger.d("LocalPlaylistRepo", "Auto sync disabled, skip") - return } GitHubSyncWorker.scheduleDelayedSync(context, triggerByUserAction = false) + WebDavSyncWorker.scheduleDelayedSync(context, triggerByUserAction = false) } catch (e: Exception) { NPLogger.e("LocalPlaylistRepo", "Failed to schedule sync", e) } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepository.kt b/app/src/main/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepository.kt index 85f4ec51..b9312b49 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepository.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepository.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext import moe.ouom.neriplayer.data.model.identity import moe.ouom.neriplayer.data.sync.github.GitHubSyncWorker import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncWorker import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.util.NPLogger import java.io.File @@ -128,10 +129,8 @@ class FavoritePlaylistRepository private constructor(private val context: Contex try { val storage = SecureTokenStorage(context) storage.markSyncMutation() - if (!storage.isAutoSyncEnabled()) { - return - } GitHubSyncWorker.scheduleDelayedSync(context, triggerByUserAction = false) + WebDavSyncWorker.scheduleDelayedSync(context, triggerByUserAction = false) } catch (e: Exception) { NPLogger.e("FavoritePlaylistRepo", "Failed to schedule sync", e) } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavApiClient.kt b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavApiClient.kt new file mode 100644 index 00000000..f903b3f7 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavApiClient.kt @@ -0,0 +1,165 @@ +package moe.ouom.neriplayer.data.sync.webdav + +import android.content.Context +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.core.di.AppContainer +import moe.ouom.neriplayer.util.NPLogger +import okhttp3.Credentials +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.security.MessageDigest + +class WebDavAuthException(message: String) : IOException(message) + +class WebDavFileNotFoundException(message: String) : IOException(message) + +class WebDavSyncInProgressException(message: String) : IOException(message) + +class WebDavApiException( + val statusCode: Int, + message: String +) : IOException(message) + +class WebDavApiClient( + private val context: Context, + private val username: String, + private val password: String +) { + private val client: OkHttpClient = AppContainer.sharedOkHttpClient + private val authorizationHeader = Credentials.basic(username, password) + + companion object { + private const val TAG = "WebDavApiClient" + private const val DEFAULT_REMOTE_FILE_NAME = "neriplayer-sync.json" + + fun calculateFingerprint(content: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(content.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } + + fun buildRemoteFileUrl(serverUrl: String, basePath: String): String { + val normalizedServerUrl = serverUrl.trim().trimEnd('/') + val normalizedBasePath = basePath.trim().trim('/') + val urlBuilder = normalizedServerUrl.toHttpUrl().newBuilder() + if (normalizedBasePath.isNotBlank()) { + normalizedBasePath + .split('/') + .filter(String::isNotBlank) + .forEach(urlBuilder::addPathSegment) + } + urlBuilder.addPathSegment(DEFAULT_REMOTE_FILE_NAME) + return urlBuilder.build().toString() + } + } + + suspend fun validateConnection(serverUrl: String, basePath: String): Result { + return runCatching { + val remoteUrl = buildRemoteFileUrl(serverUrl, basePath) + val request = Request.Builder() + .url(remoteUrl) + .header("Authorization", authorizationHeader) + .get() + .build() + + client.newCall(request).execute().use { response -> + when { + response.isSuccessful || response.code == 404 -> Unit + response.code == 401 || response.code == 403 -> { + throw WebDavAuthException( + context.getString(R.string.webdav_auth_failed) + ) + } + + else -> { + val errorBody = response.body?.string().orEmpty() + throw WebDavApiException( + response.code, + "WebDAV validate failed: ${response.code}${errorBody.takeIf { it.isNotBlank() }?.let { " - $it" } ?: ""}" + ) + } + } + } + }.onFailure { + NPLogger.e(TAG, "Validate WebDAV connection failed", it) + } + } + + suspend fun getFileContentStrict(remoteUrl: String): Result> { + return runCatching { + val request = Request.Builder() + .url(remoteUrl) + .header("Authorization", authorizationHeader) + .get() + .build() + + client.newCall(request).execute().use { response -> + when { + response.isSuccessful -> { + val body = response.body?.string() + ?: throw IOException("Empty response") + body to calculateFingerprint(body) + } + + response.code == 401 || response.code == 403 -> { + throw WebDavAuthException( + context.getString(R.string.webdav_auth_failed) + ) + } + + response.code == 404 -> { + throw WebDavFileNotFoundException("Remote backup file not found: $remoteUrl") + } + + else -> { + val errorBody = response.body?.string().orEmpty() + throw WebDavApiException( + response.code, + "Failed to get file: ${response.code}${errorBody.takeIf { it.isNotBlank() }?.let { " - $it" } ?: ""}" + ) + } + } + } + }.onFailure { + NPLogger.e(TAG, "Get WebDAV file content failed", it) + } + } + + suspend fun updateFileContent( + remoteUrl: String, + content: String + ): Result { + return runCatching { + val request = Request.Builder() + .url(remoteUrl) + .header("Authorization", authorizationHeader) + .put(content.toRequestBody("application/json; charset=utf-8".toMediaType())) + .build() + + client.newCall(request).execute().use { response -> + when { + response.isSuccessful -> calculateFingerprint(content) + response.code == 401 || response.code == 403 -> { + throw WebDavAuthException( + context.getString(R.string.webdav_auth_failed) + ) + } + + else -> { + val errorBody = response.body?.string().orEmpty() + throw WebDavApiException( + response.code, + "Failed to update file: ${response.code}${errorBody.takeIf { it.isNotBlank() }?.let { " - $it" } ?: ""}" + ) + } + } + } + }.onFailure { + NPLogger.e(TAG, "Update WebDAV file content failed", it) + } + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavStorage.kt b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavStorage.kt new file mode 100644 index 00000000..96559c3b --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavStorage.kt @@ -0,0 +1,92 @@ +package moe.ouom.neriplayer.data.sync.webdav + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class WebDavStorage(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val encryptedPrefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + "webdav_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + companion object { + private const val KEY_SERVER_URL = "server_url" + private const val KEY_BASE_PATH = "base_path" + private const val KEY_USERNAME = "username" + private const val KEY_PASSWORD = "password" + private const val KEY_LAST_SYNC_TIME = "last_sync_time" + private const val KEY_AUTO_SYNC_ENABLED = "auto_sync_enabled" + private const val KEY_LAST_REMOTE_FINGERPRINT = "last_remote_fingerprint" + } + + fun saveConfiguration( + serverUrl: String, + username: String, + password: String, + basePath: String + ) { + encryptedPrefs.edit { + putString(KEY_SERVER_URL, normalizeServerUrl(serverUrl)) + putString(KEY_BASE_PATH, normalizeBasePath(basePath)) + putString(KEY_USERNAME, username) + putString(KEY_PASSWORD, password) + } + } + + fun getServerUrl(): String? = encryptedPrefs.getString(KEY_SERVER_URL, null) + + fun getBasePath(): String = encryptedPrefs.getString(KEY_BASE_PATH, null).orEmpty() + + fun getUsername(): String? = encryptedPrefs.getString(KEY_USERNAME, null) + + fun getPassword(): String? = encryptedPrefs.getString(KEY_PASSWORD, null) + + fun getRemoteFileUrl(): String? { + val serverUrl = getServerUrl()?.takeIf { it.isNotBlank() } ?: return null + return WebDavApiClient.buildRemoteFileUrl(serverUrl, getBasePath()) + } + + fun saveLastSyncTime(timestamp: Long) { + encryptedPrefs.edit { putLong(KEY_LAST_SYNC_TIME, timestamp) } + } + + fun getLastSyncTime(): Long = encryptedPrefs.getLong(KEY_LAST_SYNC_TIME, 0L) + + fun setAutoSyncEnabled(enabled: Boolean) { + encryptedPrefs.edit { putBoolean(KEY_AUTO_SYNC_ENABLED, enabled) } + } + + fun isAutoSyncEnabled(): Boolean = encryptedPrefs.getBoolean(KEY_AUTO_SYNC_ENABLED, false) + + fun saveLastRemoteFingerprint(fingerprint: String) { + encryptedPrefs.edit { putString(KEY_LAST_REMOTE_FINGERPRINT, fingerprint) } + } + + fun getLastRemoteFingerprint(): String? = + encryptedPrefs.getString(KEY_LAST_REMOTE_FINGERPRINT, null) + + fun isConfigured(): Boolean { + return !getServerUrl().isNullOrBlank() && + !getUsername().isNullOrBlank() && + !getPassword().isNullOrBlank() + } + + fun clearAll() { + encryptedPrefs.edit { clear() } + } + + private fun normalizeServerUrl(serverUrl: String): String = serverUrl.trim().trimEnd('/') + + private fun normalizeBasePath(basePath: String): String = basePath.trim().trim('/') + +} diff --git a/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncManager.kt b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncManager.kt new file mode 100644 index 00000000..bff11192 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncManager.kt @@ -0,0 +1,964 @@ +package moe.ouom.neriplayer.data.sync.webdav + +/* + * NeriPlayer - A unified Android player for streaming music and videos from multiple online platforms. + * Copyright (C) 2025-2025 NeriPlayer developers + * https://github.com/cwuom/NeriPlayer + * + * This software is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software. + * If not, see . + * + * File: moe.ouom.neriplayer.data.sync.github/GitHubSyncManager + * Updated: 2026/3/23 + */ + + +import android.content.Context +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.data.history.PlayedEntry +import moe.ouom.neriplayer.data.playlist.favorite.FavoritePlaylistRepository +import moe.ouom.neriplayer.data.local.playlist.system.FavoritesPlaylist +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist +import moe.ouom.neriplayer.data.local.playlist.model.LocalPlaylist +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.history.PlayHistoryRepository +import moe.ouom.neriplayer.data.local.playlist.system.SystemLocalPlaylists +import moe.ouom.neriplayer.data.model.identity +import moe.ouom.neriplayer.data.model.stableKey +import moe.ouom.neriplayer.data.sync.github.ConflictResolution +import moe.ouom.neriplayer.data.sync.github.ConflictType +import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage +import moe.ouom.neriplayer.data.sync.github.SyncConflict +import moe.ouom.neriplayer.data.sync.github.SyncData +import moe.ouom.neriplayer.data.sync.github.SyncDataSerializer +import moe.ouom.neriplayer.data.sync.github.SyncFavoritePlaylist +import moe.ouom.neriplayer.data.sync.github.SyncPlaylist +import moe.ouom.neriplayer.data.sync.github.SyncRecentPlay +import moe.ouom.neriplayer.data.sync.github.SyncRecentPlayDeletion +import moe.ouom.neriplayer.data.sync.github.SyncResult +import moe.ouom.neriplayer.data.sync.github.SyncSong +import moe.ouom.neriplayer.util.LanguageManager +import moe.ouom.neriplayer.util.NPLogger +import java.io.IOException + +class WebDavSyncManager private constructor(context: Context) { + private val appContext = context.applicationContext + private val storage = SecureTokenStorage(appContext) + private val webDavStorage = WebDavStorage(appContext) + private val playlistRepo = LocalPlaylistRepository.getInstance(appContext) + private val favoriteRepo = FavoritePlaylistRepository.getInstance(appContext) + private val playHistoryRepo = PlayHistoryRepository.getInstance(appContext) + private val syncLock = Mutex() + + companion object { + private const val TAG = "WebDavSyncManager" + + @Volatile + private var instance: WebDavSyncManager? = null + + fun getInstance(context: Context): WebDavSyncManager { + return instance ?: synchronized(this) { + instance ?: WebDavSyncManager(context.applicationContext).also { instance = it } + } + } + } + + suspend fun performSync(): Result = withContext(Dispatchers.IO) { + val localizedContext = LanguageManager.applyLanguage(appContext) + + if (!syncLock.tryLock()) { + return@withContext Result.failure( + WebDavSyncInProgressException( + localizedContext.getString(R.string.webdav_sync_in_progress) + ) + ) + } + + try { + val remoteUrl = webDavStorage.getRemoteFileUrl() + val username = webDavStorage.getUsername() + val password = webDavStorage.getPassword() + if (remoteUrl == null || username == null || password == null) { + return@withContext Result.failure( + IllegalStateException(localizedContext.getString(R.string.webdav_not_configured)) + ) + } + + val apiClient = WebDavApiClient(appContext, username, password) + val startMutationVersion = storage.getSyncMutationVersion() + val localData = sanitizeSyncData(buildLocalSyncData(localizedContext)) + val uploadedDeletedPlaylistIds = localData.playlists + .asSequence() + .filter(SyncPlaylist::isDeleted) + .map(SyncPlaylist::id) + .toSet() + val remoteResult = apiClient.getFileContentStrict(remoteUrl) + + if (remoteResult.isFailure) { + val error = remoteResult.exceptionOrNull() + if (error is WebDavFileNotFoundException) { + return@withContext handleInitialUpload( + apiClient = apiClient, + remoteUrl = remoteUrl, + localData = localData, + localizedContext = localizedContext, + startMutationVersion = startMutationVersion, + uploadedDeletedPlaylistIds = uploadedDeletedPlaylistIds + ) + } + return@withContext Result.failure( + error ?: IOException(localizedContext.getString(R.string.webdav_sync_failed_message)) + ) + } + + val (remoteContent, remoteFingerprint) = remoteResult.getOrThrow() + if (remoteContent.isEmpty()) { + return@withContext Result.failure( + IOException(localizedContext.getString(R.string.webdav_backup_file_invalid)) + ) + } + + val remoteData = try { + sanitizeSyncData(SyncDataSerializer.deserialize(remoteContent, false)) + } catch (e: Exception) { + NPLogger.e(TAG, "Failed to parse remote data", e) + return@withContext Result.failure(e) + } + + val lastRemoteFingerprint = webDavStorage.getLastRemoteFingerprint() + val isFirstSync = lastRemoteFingerprint == null + val remoteHasChanged = + lastRemoteFingerprint != null && lastRemoteFingerprint != remoteFingerprint + val lastSyncTime = webDavStorage.getLastSyncTime() + val mergeResult = performThreeWayMerge(localData, remoteData, lastSyncTime) + val localMutatedDuringSync = + storage.getSyncMutationVersion() != startMutationVersion + + if (!localMutatedDuringSync) { + applyMergedDataToLocal( + mergedData = mergeResult.mergedData, + remoteHasChanged = isFirstSync || remoteHasChanged + ) + } else { + NPLogger.w(TAG, "Skip applying merged sync data because local state changed during sync") + } + + if (!hasDataChanged(remoteData, mergeResult.mergedData) && !remoteHasChanged) { + webDavStorage.saveLastRemoteFingerprint(remoteFingerprint) + webDavStorage.saveLastSyncTime(System.currentTimeMillis()) + if (localMutatedDuringSync) { + WebDavSyncWorker.scheduleDelayedSync( + appContext, + triggerByUserAction = false, + markMutation = false + ) + } + return@withContext Result.success( + SyncResult( + success = true, + message = localizedContext.getString(R.string.webdav_sync_no_change) + ) + ) + } + + val uploadResult = uploadLocalData( + apiClient = apiClient, + remoteUrl = remoteUrl, + data = mergeResult.mergedData + ) + + if (uploadResult.isFailure) { + return@withContext Result.failure( + uploadResult.exceptionOrNull() + ?: Exception(localizedContext.getString(R.string.sync_upload_failed)) + ) + } + + uploadResult.getOrNull()?.let(webDavStorage::saveLastRemoteFingerprint) + webDavStorage.saveLastSyncTime(System.currentTimeMillis()) + storage.removeDeletedPlaylistIds(uploadedDeletedPlaylistIds) + if (localMutatedDuringSync) { + WebDavSyncWorker.scheduleDelayedSync( + appContext, + triggerByUserAction = false, + markMutation = false + ) + } + Result.success(mergeResult.syncResult) + } catch (e: Exception) { + NPLogger.e(TAG, "Sync failed", e) + Result.failure(e) + } finally { + syncLock.unlock() + } + } + + private suspend fun handleInitialUpload( + apiClient: WebDavApiClient, + remoteUrl: String, + localData: SyncData, + localizedContext: Context, + startMutationVersion: Long, + uploadedDeletedPlaylistIds: Set + ): Result { + val uploadResult = uploadLocalData( + apiClient = apiClient, + remoteUrl = remoteUrl, + data = localData + ) + if (uploadResult.isSuccess) { + uploadResult.getOrNull()?.let(webDavStorage::saveLastRemoteFingerprint) + webDavStorage.saveLastSyncTime(System.currentTimeMillis()) + storage.removeDeletedPlaylistIds(uploadedDeletedPlaylistIds) + if (storage.getSyncMutationVersion() != startMutationVersion) { + WebDavSyncWorker.scheduleDelayedSync( + appContext, + triggerByUserAction = false, + markMutation = false + ) + } + return Result.success( + SyncResult( + success = true, + message = localizedContext.getString(R.string.sync_initial_uploaded) + ) + ) + } + + return Result.failure( + uploadResult.exceptionOrNull() + ?: IOException(localizedContext.getString(R.string.sync_upload_failed)) + ) + } + + private fun buildLocalSyncData(localizedContext: Context): SyncData { + val playlists = playlistRepo.playlists.value + val syncPlaylists = playlists.map { playlist -> + SyncPlaylist.fromLocalPlaylist(playlist, playlist.modifiedAt, localizedContext) + }.toMutableList() + + storage.getDeletedPlaylistIds().forEach { deletedId -> + if (playlists.none { it.id == deletedId }) { + syncPlaylists += SyncPlaylist( + id = deletedId, + name = "", + songs = emptyList(), + createdAt = 0L, + modifiedAt = System.currentTimeMillis(), + isDeleted = true + ) + } + } + + val syncFavoritePlaylists = favoriteRepo.getSyncSnapshots().map { + SyncFavoritePlaylist.fromFavoritePlaylist(it, localizedContext) + } + + val syncRecentPlays = playHistoryRepo.historyFlow.value + .filterNot { LocalSongSupport.isLocalSong(it.album, it.mediaUri, it.albumId, localizedContext) } + .take(500) + .map { playedEntry -> + SyncRecentPlay( + songId = playedEntry.id, + song = SyncSong( + id = playedEntry.id, + name = playedEntry.name, + artist = playedEntry.artist, + album = playedEntry.album, + albumId = playedEntry.albumId, + durationMs = playedEntry.durationMs, + coverUrl = playedEntry.coverUrl, + mediaUri = LocalSongSupport.sanitizeMediaUriForSync(playedEntry.mediaUri), + matchedLyric = playedEntry.matchedLyric, + matchedTranslatedLyric = playedEntry.matchedTranslatedLyric, + customCoverUrl = playedEntry.customCoverUrl, + customName = playedEntry.customName, + customArtist = playedEntry.customArtist, + originalName = playedEntry.originalName, + originalArtist = playedEntry.originalArtist, + originalCoverUrl = playedEntry.originalCoverUrl, + originalLyric = playedEntry.originalLyric, + originalTranslatedLyric = playedEntry.originalTranslatedLyric + ), + playedAt = playedEntry.playedAt, + deviceId = getDeviceId() + ) + } + val syncRecentPlayDeletions = storage.getRecentPlayDeletions() + .map { + it.copy(mediaUri = LocalSongSupport.sanitizeMediaUriForSync(it.mediaUri)) + } + + return SyncData( + deviceId = getDeviceId(), + deviceName = getDeviceName(), + lastModified = System.currentTimeMillis(), + playlists = syncPlaylists, + favoritePlaylists = syncFavoritePlaylists, + recentPlays = syncRecentPlays, + syncLog = emptyList(), + recentPlayDeletions = syncRecentPlayDeletions + ) + } + + private fun performThreeWayMerge( + local: SyncData, + remote: SyncData, + lastSyncTime: Long + ): MergeResult { + val localizedContext = LanguageManager.applyLanguage(appContext) + val conflicts = mutableListOf() + var playlistsAdded = 0 + var playlistsUpdated = 0 + var playlistsDeleted = 0 + var songsAdded = 0 + var songsRemoved = 0 + + val mergedPlaylistsById = linkedMapOf() + val localPlaylistsMap = local.playlists.associateBy { it.id } + val remotePlaylistsMap = remote.playlists.associateBy { it.id } + val allPlaylistIds = (localPlaylistsMap.keys + remotePlaylistsMap.keys).toSet() + + for (playlistId in allPlaylistIds) { + val localPlaylist = localPlaylistsMap[playlistId] + val remotePlaylist = remotePlaylistsMap[playlistId] + when { + localPlaylist != null && remotePlaylist == null -> { + if (!localPlaylist.isDeleted) { + mergedPlaylistsById[localPlaylist.id] = localPlaylist + playlistsAdded++ + } else { + playlistsDeleted++ + } + } + + localPlaylist == null && remotePlaylist != null -> { + if (!remotePlaylist.isDeleted) { + mergedPlaylistsById[remotePlaylist.id] = remotePlaylist + playlistsAdded++ + } else { + playlistsDeleted++ + } + } + + localPlaylist != null && remotePlaylist != null -> { + if (localPlaylist.isDeleted || remotePlaylist.isDeleted) { + playlistsDeleted++ + } else { + val merged = mergePlaylist(localPlaylist, remotePlaylist, lastSyncTime) + mergedPlaylistsById[merged.playlist.id] = merged.playlist + merged.conflict?.let { conflicts += it } + songsAdded += merged.songsAdded + songsRemoved += merged.songsRemoved + if (merged.isUpdated) { + playlistsUpdated++ + } + } + } + } + } + + val mergedFavoritePlaylists = (local.favoritePlaylists + remote.favoritePlaylists) + .groupBy { "${it.id}_${it.source}" } + .map { (_, snapshots) -> + snapshots.reduce(::mergeFavoritePlaylist) + } + .sortedByDescending { it.sortOrder } + + val mergedRecentPlayDeletions = pruneRecentPlayDeletions( + mergeRecentPlayDeletions(local.recentPlayDeletions, remote.recentPlayDeletions), + local.recentPlays + remote.recentPlays + ) + val mergedPlaylists = orderMergedPlaylists( + local = local.playlists, + remote = remote.playlists, + mergedById = mergedPlaylistsById, + lastSyncTime = lastSyncTime + ) + val mergedRecentPlays = mergeRecentPlays( + local = local.recentPlays, + remote = remote.recentPlays, + deletions = mergedRecentPlayDeletions + ) + + val mergedData = SyncData( + deviceId = local.deviceId, + deviceName = local.deviceName, + lastModified = System.currentTimeMillis(), + playlists = mergedPlaylists, + favoritePlaylists = mergedFavoritePlaylists, + recentPlays = mergedRecentPlays, + syncLog = (local.syncLog + remote.syncLog) + .distinctBy { it.timestamp } + .sortedByDescending { it.timestamp } + .take(100), + recentPlayDeletions = mergedRecentPlayDeletions + ) + + return MergeResult( + mergedData = mergedData, + syncResult = SyncResult( + success = true, + message = localizedContext.getString(R.string.webdav_sync_success_detail), + playlistsAdded = playlistsAdded, + playlistsUpdated = playlistsUpdated, + playlistsDeleted = playlistsDeleted, + songsAdded = songsAdded, + songsRemoved = songsRemoved, + conflicts = conflicts + ) + ) + } + + private fun orderMergedPlaylists( + local: List, + remote: List, + mergedById: Map, + lastSyncTime: Long + ): List { + if (mergedById.isEmpty()) return emptyList() + + val localChangedAfterSync = hasPlaylistCollectionChangedAfterSync(local, lastSyncTime) + val remoteChangedAfterSync = hasPlaylistCollectionChangedAfterSync(remote, lastSyncTime) + val primary = if (remoteChangedAfterSync && !localChangedAfterSync) remote else local + val secondary = if (primary === local) remote else local + val orderedIds = LinkedHashSet() + + fun appendPlaylistIds(source: List) { + source.asSequence() + .filterNot(SyncPlaylist::isDeleted) + .map(SyncPlaylist::id) + .filter(mergedById::containsKey) + .forEach(orderedIds::add) + } + + appendPlaylistIds(primary) + appendPlaylistIds(secondary) + mergedById.keys.forEach(orderedIds::add) + + return orderedIds.mapNotNull(mergedById::get) + } + + private fun hasPlaylistCollectionChangedAfterSync( + playlists: List, + lastSyncTime: Long + ): Boolean { + return playlists.any { it.modifiedAt > lastSyncTime } + } + + private fun mergePlaylist( + local: SyncPlaylist, + remote: SyncPlaylist, + lastSyncTime: Long + ): PlaylistMergeResult { + val localizedContext = LanguageManager.applyLanguage(appContext) + var conflict: SyncConflict? = null + var hasConflict = false + var isUpdated = false + + val systemDescriptor = SystemLocalPlaylists.resolve(local.id, local.name, localizedContext) + ?: SystemLocalPlaylists.resolve(remote.id, remote.name, localizedContext) + val isFavorites = systemDescriptor?.id == FavoritesPlaylist.SYSTEM_ID + val localChangedAfterSync = local.modifiedAt > lastSyncTime + val remoteChangedAfterSync = remote.modifiedAt > lastSyncTime + + val finalName = when { + systemDescriptor != null -> systemDescriptor.currentName + local.name == remote.name -> local.name + remoteChangedAfterSync && !localChangedAfterSync -> { + hasConflict = true + isUpdated = true + conflict = SyncConflict( + type = ConflictType.PLAYLIST_RENAMED_BOTH_SIDES, + playlistId = remote.id, + playlistName = remote.name, + description = localizedContext.getString(R.string.github_playlist_renamed_remote, remote.name), + resolution = ConflictResolution.REMOTE_WINS + ) + remote.name + } + localChangedAfterSync && !remoteChangedAfterSync -> { + hasConflict = true + conflict = SyncConflict( + type = ConflictType.PLAYLIST_RENAMED_BOTH_SIDES, + playlistId = local.id, + playlistName = local.name, + description = localizedContext.getString(R.string.github_playlist_renamed_local, local.name), + resolution = ConflictResolution.LOCAL_WINS + ) + local.name + } + else -> { + hasConflict = true + conflict = SyncConflict( + type = ConflictType.PLAYLIST_RENAMED_BOTH_SIDES, + playlistId = local.id, + playlistName = local.name, + description = localizedContext.getString(R.string.github_playlist_renamed_local, local.name), + resolution = ConflictResolution.MANUAL_REQUIRED + ) + local.name + } + } + + val localSongs = local.songs.map { it.identity() }.toSet() + val remoteSongs = remote.songs.map { it.identity() }.toSet() + val preferRemoteFavorites = isFavorites && localSongs.isEmpty() && remoteSongs.isNotEmpty() + + fun mergeSongsPreservingLocal( + localList: List, + remoteList: List + ): List { + val merged = localList.toMutableList() + val known = localList.map { it.identity() }.toMutableSet() + remoteList.forEach { song -> + if (known.add(song.identity())) { + merged += song + } + } + return merged + } + + val mergedSongs = when { + remoteSongs.isEmpty() && localSongs.isNotEmpty() -> local.songs + localSongs.isEmpty() && remoteSongs.isNotEmpty() -> { + isUpdated = true + remote.songs + } + preferRemoteFavorites && !localChangedAfterSync -> { + isUpdated = true + remote.songs + } + remoteChangedAfterSync && !localChangedAfterSync -> { + isUpdated = true + remote.songs + } + localChangedAfterSync && !remoteChangedAfterSync -> local.songs + else -> { + val merged = mergeSongsPreservingLocal(local.songs, remote.songs) + if (merged.size != local.songs.size || merged.size != remote.songs.size) { + isUpdated = true + } + merged + } + } + + val mergedIdentities = mergedSongs.map { it.identity() }.toSet() + val songsAdded = (mergedIdentities - localSongs).size + val songsRemoved = (localSongs - mergedIdentities).size + + return PlaylistMergeResult( + playlist = SyncPlaylist( + id = systemDescriptor?.id ?: local.id, + name = finalName, + songs = mergedSongs, + createdAt = minOf(local.createdAt, remote.createdAt), + modifiedAt = maxOf(local.modifiedAt, remote.modifiedAt) + ), + hasConflict = hasConflict, + conflict = conflict, + songsAdded = songsAdded, + songsRemoved = songsRemoved, + isUpdated = isUpdated + ) + } + + private fun mergeRecentPlays( + local: List, + remote: List, + deletions: List + ): List { + val deletionByIdentity = deletions.associateBy { it.identity().stableKey() } + return (local + remote) + .sortedByDescending { it.playedAt } + .distinctBy { it.song.identity().stableKey() } + .filter { recentPlay -> + val deletion = deletionByIdentity[recentPlay.song.identity().stableKey()] + deletion == null || recentPlay.playedAt > deletion.deletedAt + } + .take(500) + } + + private fun mergeRecentPlayDeletions( + local: List, + remote: List + ): List { + return (local + remote) + .groupBy { it.identity().stableKey() } + .mapNotNull { (_, snapshots) -> + snapshots.maxWithOrNull( + compareBy { it.deletedAt } + .thenBy { it.deviceId } + ) + } + .sortedByDescending { it.deletedAt } + .take(500) + } + + private fun pruneRecentPlayDeletions( + deletions: List, + recentPlays: List + ): List { + val latestPlayByIdentity = recentPlays + .groupBy { it.song.identity().stableKey() } + .mapValues { (_, plays) -> plays.maxOf { it.playedAt } } + return deletions + .filter { deletion -> + val latestPlay = latestPlayByIdentity[deletion.identity().stableKey()] + latestPlay == null || latestPlay <= deletion.deletedAt + } + .sortedByDescending { it.deletedAt } + .take(500) + } + + private fun mergeFavoritePlaylist( + left: SyncFavoritePlaylist, + right: SyncFavoritePlaylist + ): SyncFavoritePlaylist { + val newer = if (right.modifiedAt > left.modifiedAt) right else left + val older = if (newer === left) right else left + + if (left.isDeleted != right.isDeleted) { + return if (left.modifiedAt == right.modifiedAt) { + newer.copy( + songs = if (newer.isDeleted) emptyList() else (left.songs + right.songs).distinctBy { it.identity() }, + trackCount = if (newer.isDeleted) 0 else maxOf(left.trackCount, right.trackCount, left.songs.size, right.songs.size) + ) + } else { + if (newer.isDeleted) { + newer.copy( + songs = emptyList(), + trackCount = 0, + sortOrder = maxOf(left.sortOrder, right.sortOrder) + ) + } else { + newer.copy( + songs = (left.songs + right.songs).distinctBy { it.identity() }, + trackCount = maxOf(left.trackCount, right.trackCount, left.songs.size, right.songs.size), + sortOrder = newer.sortOrder.takeIf { it > 0L } ?: older.sortOrder + ) + } + } + } + + if (newer.isDeleted) { + return newer.copy( + songs = emptyList(), + trackCount = 0, + addedTime = maxOf(left.addedTime, right.addedTime), + sortOrder = maxOf(left.sortOrder, right.sortOrder) + ) + } + + val mergedSongs = (left.songs + right.songs).distinctBy { it.identity() } + return newer.copy( + coverUrl = newer.coverUrl ?: older.coverUrl, + songs = mergedSongs, + trackCount = maxOf(left.trackCount, right.trackCount, mergedSongs.size), + addedTime = maxOf(left.addedTime, right.addedTime), + modifiedAt = maxOf(left.modifiedAt, right.modifiedAt), + sortOrder = newer.sortOrder.takeIf { it > 0L } ?: older.sortOrder, + isDeleted = false + ) + } + + private suspend fun applyMergedDataToLocal(mergedData: SyncData, remoteHasChanged: Boolean) { + val localizedContext = LanguageManager.applyLanguage(appContext) + val sanitizedMergedData = sanitizeSyncData(mergedData) + val currentPlaylists = playlistRepo.playlists.value.associateBy { playlist -> + SystemLocalPlaylists.resolve(playlist.id, playlist.name, localizedContext)?.id ?: playlist.id + } + val mergedLocalPlaylists = sanitizedMergedData.playlists.map { syncPlaylist -> + val systemDescriptor = SystemLocalPlaylists.resolve( + syncPlaylist.id, + syncPlaylist.name, + localizedContext + ) + val normalizedId = systemDescriptor?.id ?: syncPlaylist.id + val syncedSongs = syncPlaylist.songs.map { it.toSongItem() } + val preservedLocalSongs = currentPlaylists[normalizedId] + ?.songs + .orEmpty() + .filter { LocalSongSupport.isLocalSong(it, localizedContext) } + + LocalPlaylist( + id = normalizedId, + name = systemDescriptor?.currentName ?: syncPlaylist.name, + songs = mergeLocalOnlySongs(syncedSongs, preservedLocalSongs), + modifiedAt = syncPlaylist.modifiedAt, + customCoverUrl = currentPlaylists[normalizedId]?.customCoverUrl + ) + } + playlistRepo.updatePlaylists(mergedLocalPlaylists) + + favoriteRepo.replaceFavoritesFromSync( + sanitizedMergedData.favoritePlaylists.map { it.toFavoritePlaylist() } + ) + storage.setRecentPlayDeletions(sanitizedMergedData.recentPlayDeletions) + + val localPlayHistoryEmpty = playHistoryRepo.historyFlow.value.isEmpty() + val shouldApplyRemoteHistory = remoteHasChanged || + (localPlayHistoryEmpty && sanitizedMergedData.recentPlays.isNotEmpty()) + + if (shouldApplyRemoteHistory) { + val syncedHistory = sanitizedMergedData.recentPlays.mapNotNull { syncPlay -> + if (LocalSongSupport.isLocalSong(syncPlay.song.album, syncPlay.song.mediaUri, syncPlay.song.albumId, localizedContext)) { + return@mapNotNull null + } + + PlayedEntry( + id = syncPlay.song.id, + name = syncPlay.song.name, + artist = syncPlay.song.artist, + album = syncPlay.song.album, + albumId = syncPlay.song.albumId, + durationMs = syncPlay.song.durationMs, + coverUrl = syncPlay.song.coverUrl, + mediaUri = LocalSongSupport.sanitizeMediaUriForSync(syncPlay.song.mediaUri), + matchedLyric = syncPlay.song.matchedLyric, + matchedTranslatedLyric = syncPlay.song.matchedTranslatedLyric, + customCoverUrl = syncPlay.song.customCoverUrl, + customName = syncPlay.song.customName, + customArtist = syncPlay.song.customArtist, + originalName = syncPlay.song.originalName, + originalArtist = syncPlay.song.originalArtist, + originalCoverUrl = syncPlay.song.originalCoverUrl, + originalLyric = syncPlay.song.originalLyric, + originalTranslatedLyric = syncPlay.song.originalTranslatedLyric, + playedAt = syncPlay.playedAt + ) + } + val localOnlyHistory = playHistoryRepo.historyFlow.value.filter { + LocalSongSupport.isLocalSong(it.album, it.mediaUri, it.albumId, localizedContext) + } + val playHistory = mergeLocalOnlyHistory(syncedHistory, localOnlyHistory) + playHistoryRepo.updateHistory(playHistory) + } + } + + private fun mergeLocalOnlySongs( + syncedSongs: List, + localOnlySongs: List + ): MutableList { + val merged = syncedSongs.toMutableList() + val knownIdentities = merged.map { it.identity() }.toMutableSet() + localOnlySongs.forEach { song -> + if (knownIdentities.add(song.identity())) { + merged += song + } + } + return merged + } + + private fun mergeLocalOnlyHistory( + syncedHistory: List, + localOnlyHistory: List + ): List { + return (syncedHistory + localOnlyHistory) + .distinctBy { "${it.id}|${it.album}|${it.mediaUri.orEmpty()}|${it.playedAt}" } + .sortedByDescending { it.playedAt } + .take(500) + } + + private fun sanitizeSyncData(data: SyncData): SyncData { + return data.copy( + playlists = data.playlists.mapNotNull { sanitizeSyncPlaylist(it) }, + favoritePlaylists = data.favoritePlaylists.map { sanitizeSyncFavoritePlaylist(it) }, + recentPlays = data.recentPlays.mapNotNull { sanitizeRecentPlay(it) }, + recentPlayDeletions = data.recentPlayDeletions.mapNotNull { sanitizeRecentPlayDeletion(it) } + ) + } + + private fun sanitizeSyncPlaylist(playlist: SyncPlaylist): SyncPlaylist? { + val localizedContext = LanguageManager.applyLanguage(appContext) + val systemDescriptor = SystemLocalPlaylists.resolve(playlist.id, playlist.name, localizedContext) + if (systemDescriptor?.id == LocalFilesPlaylist.SYSTEM_ID) { + return null + } + return playlist.copy( + id = systemDescriptor?.id ?: playlist.id, + name = systemDescriptor?.currentName ?: playlist.name, + songs = playlist.songs.mapNotNull { sanitizeSyncSong(it) } + ) + } + + private fun sanitizeSyncFavoritePlaylist(playlist: SyncFavoritePlaylist): SyncFavoritePlaylist { + return playlist.copy( + songs = if (playlist.isDeleted) { + emptyList() + } else { + playlist.songs.mapNotNull { sanitizeSyncSong(it) } + }, + trackCount = if (playlist.isDeleted) 0 else playlist.trackCount + ) + } + + private fun sanitizeRecentPlay(play: SyncRecentPlay): SyncRecentPlay? { + val sanitizedSong = sanitizeSyncSong(play.song) ?: return null + return play.copy(songId = sanitizedSong.id, song = sanitizedSong) + } + + private fun sanitizeRecentPlayDeletion( + deletion: SyncRecentPlayDeletion + ): SyncRecentPlayDeletion? { + if (LocalSongSupport.isLocalSong(deletion.album, deletion.mediaUri, 0L, appContext)) { + return null + } + return deletion.copy(mediaUri = LocalSongSupport.sanitizeMediaUriForSync(deletion.mediaUri)) + } + + private fun sanitizeSyncSong(song: SyncSong): SyncSong? { + val localizedContext = LanguageManager.applyLanguage(appContext) + if (LocalSongSupport.isLocalSong(song.album, song.mediaUri, song.albumId, localizedContext)) { + return null + } + return song.copy(mediaUri = LocalSongSupport.sanitizeMediaUriForSync(song.mediaUri)) + } + + private fun hasDataChanged(remote: SyncData, merged: SyncData): Boolean { + if (remote.playlists.size != merged.playlists.size) return true + if (remote.playlists.map(SyncPlaylist::id) != merged.playlists.map(SyncPlaylist::id)) return true + + val remotePlaylistMap = remote.playlists.associateBy { it.id } + for (mergedPlaylist in merged.playlists) { + val remotePlaylist = remotePlaylistMap[mergedPlaylist.id] ?: return true + if (remotePlaylist.name != mergedPlaylist.name) return true + if (remotePlaylist.songs.size != mergedPlaylist.songs.size) return true + if (remotePlaylist.songs.map { it.identity() } != mergedPlaylist.songs.map { it.identity() }) return true + for (i in remotePlaylist.songs.indices) { + val remoteSong = remotePlaylist.songs[i] + val mergedSong = mergedPlaylist.songs[i] + if (!sameSongMetadata(remoteSong, mergedSong)) return true + } + } + + if (remote.favoritePlaylists.size != merged.favoritePlaylists.size) return true + val remoteFavoriteMap = remote.favoritePlaylists.associateBy { "${it.id}_${it.source}" } + val mergedFavoriteMap = merged.favoritePlaylists.associateBy { "${it.id}_${it.source}" } + if (remoteFavoriteMap.keys != mergedFavoriteMap.keys) return true + remoteFavoriteMap.forEach { (key, remoteFavorite) -> + val mergedFavorite = mergedFavoriteMap[key] ?: return true + if (remoteFavorite.isDeleted != mergedFavorite.isDeleted) return true + if (remoteFavorite.modifiedAt != mergedFavorite.modifiedAt) return true + if (remoteFavorite.sortOrder != mergedFavorite.sortOrder) return true + if (remoteFavorite.trackCount != mergedFavorite.trackCount) return true + if (remoteFavorite.songs.map { it.identity() } != mergedFavorite.songs.map { it.identity() }) return true + for (i in remoteFavorite.songs.indices) { + val remoteSong = remoteFavorite.songs[i] + val mergedSong = mergedFavorite.songs[i] + if (!sameSongMetadata(remoteSong, mergedSong)) return true + } + } + + val remoteRecent = remote.recentPlays.take(50) + val mergedRecent = merged.recentPlays.take(50) + if (remoteRecent.size != mergedRecent.size) return true + for (i in remoteRecent.indices) { + if (remoteRecent[i].song.identity() != mergedRecent[i].song.identity()) return true + if (!sameSongMetadata(remoteRecent[i].song, mergedRecent[i].song)) return true + if (remoteRecent[i].playedAt != mergedRecent[i].playedAt) return true + } + + val remoteRecentDeletions = remote.recentPlayDeletions.take(100) + val mergedRecentDeletions = merged.recentPlayDeletions.take(100) + if (remoteRecentDeletions.size != mergedRecentDeletions.size) return true + for (i in remoteRecentDeletions.indices) { + if (remoteRecentDeletions[i].identity() != mergedRecentDeletions[i].identity()) return true + if (remoteRecentDeletions[i].deletedAt != mergedRecentDeletions[i].deletedAt) return true + if (remoteRecentDeletions[i].deviceId != mergedRecentDeletions[i].deviceId) return true + } + return false + } + + private fun sameSongMetadata(a: SyncSong, b: SyncSong): Boolean { + return a.name == b.name && + a.artist == b.artist && + a.album == b.album && + a.albumId == b.albumId && + a.durationMs == b.durationMs && + a.coverUrl == b.coverUrl && + a.mediaUri == b.mediaUri && + a.matchedLyric == b.matchedLyric && + a.matchedTranslatedLyric == b.matchedTranslatedLyric && + a.matchedLyricSource == b.matchedLyricSource && + a.matchedSongId == b.matchedSongId && + a.userLyricOffsetMs == b.userLyricOffsetMs && + a.customCoverUrl == b.customCoverUrl && + a.customName == b.customName && + a.customArtist == b.customArtist && + a.originalName == b.originalName && + a.originalArtist == b.originalArtist && + a.originalCoverUrl == b.originalCoverUrl && + a.originalLyric == b.originalLyric && + a.originalTranslatedLyric == b.originalTranslatedLyric && + a.channelId == b.channelId && + a.audioId == b.audioId && + a.subAudioId == b.subAudioId && + a.playlistContextId == b.playlistContextId + } + + private suspend fun uploadLocalData( + apiClient: WebDavApiClient, + remoteUrl: String, + data: SyncData + ): Result { + val localizedContext = LanguageManager.applyLanguage(appContext) + val content = SyncDataSerializer.serialize(data, false) + NPLogger.d( + TAG, + "Upload data size: ${SyncDataSerializer.getDataSize(data, false)} bytes (WebDAV)" + ) + + val uploadResult = apiClient.updateFileContent(remoteUrl, content) + return if (uploadResult.isSuccess) { + Result.success(uploadResult.getOrNull().orEmpty()) + } else { + Result.failure( + uploadResult.exceptionOrNull() + ?: Exception(localizedContext.getString(R.string.sync_upload_failed)) + ) + } + } + + private fun getDeviceId(): String { + return storage.getOrCreateDeviceId() + } + + private fun getDeviceName(): String { + return try { + "${Build.MANUFACTURER} ${Build.MODEL}" + } catch (_: Exception) { + "Unknown Device" + } + } + + private data class MergeResult( + val mergedData: SyncData, + val syncResult: SyncResult + ) + + private data class PlaylistMergeResult( + val playlist: SyncPlaylist, + val hasConflict: Boolean, + val conflict: SyncConflict?, + val songsAdded: Int, + val songsRemoved: Int, + val isUpdated: Boolean + ) +} diff --git a/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncWorker.kt b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncWorker.kt new file mode 100644 index 00000000..1ec66c84 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncWorker.kt @@ -0,0 +1,155 @@ +package moe.ouom.neriplayer.data.sync.webdav + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage +import moe.ouom.neriplayer.util.NPLogger +import java.util.concurrent.TimeUnit + +class WebDavSyncWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "WebDavSyncWorker" + private const val WORK_NAME = "webdav_sync_work" + private const val PERIODIC_WORK_NAME = "webdav_sync_periodic" + private const val NOTIFICATION_CHANNEL_ID = "webdav_sync_channel" + private const val NOTIFICATION_ID = 1002 + + fun scheduleDelayedSync( + context: Context, + triggerByUserAction: Boolean = false, + markMutation: Boolean = false + ) { + if (markMutation) { + SecureTokenStorage(context).markSyncMutation() + } + val syncRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(5, TimeUnit.SECONDS) + .addTag(WORK_NAME) + .setInputData(workDataOf("trigger_by_user_action" to triggerByUserAction)) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, syncRequest) + } + + fun schedulePeriodicSync(context: Context) { + val syncRequest = PeriodicWorkRequestBuilder( + 1, TimeUnit.HOURS, + 15, TimeUnit.MINUTES + ).addTag(PERIODIC_WORK_NAME).build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + + fun cancelAllSync(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_NAME) + WorkManager.getInstance(context).cancelAllWorkByTag(PERIODIC_WORK_NAME) + } + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val forceSync = inputData.getBoolean("force_sync", false) + val triggerByUserAction = inputData.getBoolean("trigger_by_user_action", false) + try { + NPLogger.d(TAG, "Starting WebDAV sync...") + + val storage = WebDavStorage(applicationContext) + if (!forceSync && !triggerByUserAction && !storage.isAutoSyncEnabled()) { + NPLogger.d(TAG, "WebDAV auto sync is disabled") + return@withContext Result.success() + } + if (!storage.isConfigured()) { + NPLogger.d(TAG, "WebDAV not configured") + return@withContext Result.success() + } + if (!hasValidatedNetwork()) { + NPLogger.d(TAG, "No validated network available, retry later") + return@withContext Result.retry() + } + + val syncResult = WebDavSyncManager.getInstance(applicationContext).performSync() + if (syncResult.isSuccess) { + NPLogger.d(TAG, "WebDAV sync completed: ${syncResult.getOrNull()?.message}") + Result.success() + } else { + val error = syncResult.exceptionOrNull() + if (error is WebDavSyncInProgressException) { + return@withContext Result.success() + } + NPLogger.e(TAG, "WebDAV sync failed", error) + if (forceSync || triggerByUserAction || error is WebDavAuthException) { + showErrorNotification(error) + } + if (error is WebDavAuthException) Result.failure() else Result.retry() + } + } catch (e: Exception) { + NPLogger.e(TAG, "WebDAV sync worker error", e) + if (forceSync || triggerByUserAction) { + showErrorNotification(e) + } + Result.retry() + } + } + + private fun hasValidatedNetwork(): Boolean { + val connectivityManager = + applicationContext.getSystemService(ConnectivityManager::class.java) ?: return false + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + private fun showErrorNotification(error: Throwable?) { + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + applicationContext.getString(R.string.webdav_sync_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.webdav_sync_channel_desc) + } + notificationManager.createNotificationChannel(channel) + + val errorMessage = when (error) { + is WebDavAuthException -> applicationContext.getString(R.string.webdav_auth_failed) + else -> error?.message ?: applicationContext.getString(R.string.webdav_sync_failed_message) + } + + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(applicationContext.getString(R.string.webdav_sync_failed_title)) + .setContentText(errorMessage) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt index f3b3a780..155ef356 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt @@ -146,6 +146,7 @@ import moe.ouom.neriplayer.ui.screen.tab.settings.component.maskCookieValue import moe.ouom.neriplayer.ui.screen.tab.settings.component.settingsItemClickable import moe.ouom.neriplayer.ui.screen.tab.settings.dialog.SettingsGitHubDialogs import moe.ouom.neriplayer.ui.screen.tab.settings.dialog.SettingsPreferenceDialogs +import moe.ouom.neriplayer.ui.screen.tab.settings.dialog.SettingsWebDavDialogs import moe.ouom.neriplayer.ui.screen.tab.settings.state.collectAsStateWithLifecycleCompat import moe.ouom.neriplayer.ui.screen.tab.settings.state.formatSyncTime import moe.ouom.neriplayer.ui.viewmodel.BackupRestoreViewModel @@ -390,6 +391,8 @@ fun SettingsScreen( var showDpiDialog by remember { mutableStateOf(false) } var showGitHubConfigDialog by remember { mutableStateOf(false) } var showClearGitHubConfigDialog by remember { mutableStateOf(false) } + var showWebDavConfigDialog by remember { mutableStateOf(false) } + var showClearWebDavConfigDialog by remember { mutableStateOf(false) } var showListenTogetherResetUuidDialog by remember { mutableStateOf(false) } var showListenTogetherServerDialog by remember { mutableStateOf(false) } var listenTogetherServerInput by rememberSaveable { mutableStateOf("") } @@ -1602,7 +1605,9 @@ fun SettingsScreen( silentGitHubSyncFailure = silentGitHubSyncFailure, onSilentGitHubSyncFailureChange = onSilentGitHubSyncFailureChange, onOpenGitHubConfig = { showGitHubConfigDialog = true }, - onOpenClearGitHubConfig = { showClearGitHubConfigDialog = true } + onOpenClearGitHubConfig = { showClearGitHubConfigDialog = true }, + onOpenWebDavConfig = { showWebDavConfigDialog = true }, + onOpenClearWebDavConfig = { showClearWebDavConfigDialog = true } ) } @@ -1916,6 +1921,13 @@ fun SettingsScreen( onShowClearGitHubConfigDialogChange = { showClearGitHubConfigDialog = it } ) + SettingsWebDavDialogs( + showWebDavConfigDialog = showWebDavConfigDialog, + onShowWebDavConfigDialogChange = { showWebDavConfigDialog = it }, + showClearWebDavConfigDialog = showClearWebDavConfigDialog, + onShowClearWebDavConfigDialogChange = { showClearWebDavConfigDialog = it } + ) + pendingDownloadDirectoryChange?.let { pendingChange -> AlertDialog( onDismissRequest = { diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsBackupRestoreSection.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsBackupRestoreSection.kt index c7fa6092..73f31727 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsBackupRestoreSection.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsBackupRestoreSection.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.automirrored.outlined.PlaylistPlay import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Backup import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Download @@ -87,6 +88,7 @@ import moe.ouom.neriplayer.R import moe.ouom.neriplayer.data.sync.github.SecureTokenStorage import moe.ouom.neriplayer.ui.viewmodel.BackupRestoreUiState import moe.ouom.neriplayer.ui.viewmodel.GitHubSyncViewModel +import moe.ouom.neriplayer.ui.viewmodel.WebDavSyncViewModel import moe.ouom.neriplayer.ui.screen.tab.settings.state.formatSyncTime import moe.ouom.neriplayer.util.HapticTextButton @@ -104,7 +106,9 @@ internal fun SettingsBackupRestoreSection( silentGitHubSyncFailure: Boolean, onSilentGitHubSyncFailureChange: (Boolean) -> Unit, onOpenGitHubConfig: () -> Unit, - onOpenClearGitHubConfig: () -> Unit + onOpenClearGitHubConfig: () -> Unit, + onOpenWebDavConfig: () -> Unit, + onOpenClearWebDavConfig: () -> Unit ) { ExpandableHeader( icon = Icons.Outlined.Backup, @@ -123,7 +127,9 @@ internal fun SettingsBackupRestoreSection( ) { val context = androidx.compose.ui.platform.LocalContext.current val githubVm: GitHubSyncViewModel = viewModel() + val webDavVm: WebDavSyncViewModel = viewModel() val githubState by githubVm.uiState.collectAsState() + val webDavState by webDavVm.uiState.collectAsState() var showPlayHistoryModeDialog by remember { mutableStateOf(false) } val storage = remember(context) { SecureTokenStorage(context) } val currentMode = remember { mutableStateOf(storage.getPlayHistoryUpdateMode()) } @@ -133,6 +139,9 @@ internal fun SettingsBackupRestoreSection( LaunchedEffect(githubVm, context) { githubVm.initialize(context) } + LaunchedEffect(webDavVm, context) { + webDavVm.initialize(context) + } Column( modifier = Modifier @@ -354,6 +363,62 @@ internal fun SettingsBackupRestoreSection( colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) + ListItem( + leadingContent = { + Icon( + Icons.Outlined.Download, + contentDescription = stringResource(R.string.settings_data_saver), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + headlineContent = { Text(stringResource(R.string.sync_data_saver)) }, + supportingContent = { Text(stringResource(R.string.sync_data_saver_desc)) }, + trailingContent = { + Switch( + checked = dataSaverMode, + onCheckedChange = { enabled -> + if (enabled != dataSaverMode) { + pendingDataSaverMode = enabled + } + } + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + + ListItem( + leadingContent = { + Icon( + Icons.Outlined.Error, + contentDescription = stringResource(R.string.github_sync_silent_failure), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + headlineContent = { Text(stringResource(R.string.github_sync_silent_failure)) }, + supportingContent = { + Text(stringResource(R.string.github_sync_silent_failure_desc)) + }, + trailingContent = { + Switch( + checked = silentGitHubSyncFailure, + onCheckedChange = onSilentGitHubSyncFailureChange + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + + HapticTextButton( + onClick = onOpenClearGitHubConfig, + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + stringResource(R.string.settings_clear_config), + color = MaterialTheme.colorScheme.error + ) + } + } + + if (githubState.isConfigured || webDavState.isConfigured) { ListItem( leadingContent = { Icon( @@ -380,25 +445,63 @@ internal fun SettingsBackupRestoreSection( }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + ListItem( + leadingContent = { + Icon( + Icons.Outlined.Cloud, + contentDescription = stringResource(R.string.webdav_sync_title), + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { Text(stringResource(R.string.webdav_sync_title)) }, + supportingContent = { + Text( + if (webDavState.isConfigured) { + stringResource(R.string.settings_configured) + } else { + stringResource(R.string.settings_not_configured) + } + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + if (!webDavState.isConfigured) { ListItem( leadingContent = { Icon( - Icons.Outlined.Download, - contentDescription = stringResource(R.string.settings_data_saver), + Icons.Outlined.Settings, + contentDescription = stringResource(R.string.settings_configure), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - headlineContent = { Text(stringResource(R.string.sync_data_saver)) }, - supportingContent = { Text(stringResource(R.string.sync_data_saver_desc)) }, + headlineContent = { Text(stringResource(R.string.sync_config)) }, + supportingContent = { Text(stringResource(R.string.webdav_sync_desc)) }, + modifier = Modifier.settingsItemClickable(onClick = onOpenWebDavConfig), + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } else { + ListItem( + leadingContent = { + Icon( + Icons.Outlined.Sync, + contentDescription = stringResource(R.string.settings_auto_sync), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + headlineContent = { Text(stringResource(R.string.sync_auto)) }, + supportingContent = { Text(stringResource(R.string.webdav_auto_sync_desc)) }, trailingContent = { Switch( - checked = dataSaverMode, - onCheckedChange = { enabled -> - if (enabled != dataSaverMode) { - pendingDataSaverMode = enabled - } - } + checked = webDavState.autoSyncEnabled, + onCheckedChange = { webDavVm.toggleAutoSync(context, it) } ) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) @@ -407,26 +510,41 @@ internal fun SettingsBackupRestoreSection( ListItem( leadingContent = { Icon( - Icons.Outlined.Error, - contentDescription = stringResource(R.string.github_sync_silent_failure), + Icons.Outlined.CloudUpload, + contentDescription = stringResource(R.string.settings_sync_now), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - headlineContent = { Text(stringResource(R.string.github_sync_silent_failure)) }, + headlineContent = { Text(stringResource(R.string.sync_now)) }, supportingContent = { - Text(stringResource(R.string.github_sync_silent_failure_desc)) + if (webDavState.lastSyncTime > 0) { + Text( + stringResource( + R.string.sync_last_time, + formatSyncTime(webDavState.lastSyncTime) + ) + ) + } else { + Text(stringResource(R.string.sync_not_synced)) + } }, trailingContent = { - Switch( - checked = silentGitHubSyncFailure, - onCheckedChange = onSilentGitHubSyncFailureChange - ) + if (webDavState.isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + HapticTextButton(onClick = { webDavVm.performSync(context) }) { + Text(stringResource(R.string.sync_title)) + } + } }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) HapticTextButton( - onClick = onOpenClearGitHubConfig, + onClick = onOpenClearWebDavConfig, modifier = Modifier.padding(start = 16.dp) ) { Text( @@ -437,7 +555,7 @@ internal fun SettingsBackupRestoreSection( } githubState.errorMessage?.let { error -> - GitHubMessageCard( + SyncMessageCard( message = error, isSuccess = false, onClose = githubVm::clearMessages @@ -445,12 +563,28 @@ internal fun SettingsBackupRestoreSection( } githubState.successMessage?.let { message -> - GitHubMessageCard( + SyncMessageCard( message = message, isSuccess = true, onClose = githubVm::clearMessages ) } + + webDavState.errorMessage?.let { error -> + SyncMessageCard( + message = error, + isSuccess = false, + onClose = webDavVm::clearMessages + ) + } + + webDavState.successMessage?.let { message -> + SyncMessageCard( + message = message, + isSuccess = true, + onClose = webDavVm::clearMessages + ) + } } if (showPlayHistoryModeDialog) { @@ -543,7 +677,7 @@ private fun ResultStatusCard( } @Composable -private fun GitHubMessageCard( +private fun SyncMessageCard( message: String, isSuccess: Boolean, onClose: () -> Unit diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavDialogs.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavDialogs.kt new file mode 100644 index 00000000..681f1f06 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavDialogs.kt @@ -0,0 +1,175 @@ +package moe.ouom.neriplayer.ui.screen.tab.settings.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.data.sync.webdav.WebDavStorage +import moe.ouom.neriplayer.ui.viewmodel.WebDavSyncViewModel +import moe.ouom.neriplayer.util.HapticButton +import moe.ouom.neriplayer.util.HapticTextButton +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource + +@Composable +internal fun SettingsWebDavDialogs( + showWebDavConfigDialog: Boolean, + onShowWebDavConfigDialogChange: (Boolean) -> Unit, + showClearWebDavConfigDialog: Boolean, + onShowClearWebDavConfigDialogChange: (Boolean) -> Unit +) { + val context = LocalContext.current + val webDavVm: WebDavSyncViewModel = viewModel() + + LaunchedEffect(webDavVm, context) { + webDavVm.initialize(context) + } + + if (showWebDavConfigDialog) { + val webDavState by webDavVm.uiState.collectAsState() + val storage = remember(context) { WebDavStorage(context) } + var serverUrl by remember(showWebDavConfigDialog) { + mutableStateOf(storage.getServerUrl().orEmpty()) + } + var username by remember(showWebDavConfigDialog) { + mutableStateOf(storage.getUsername().orEmpty()) + } + var password by remember(showWebDavConfigDialog) { + mutableStateOf(storage.getPassword().orEmpty()) + } + var basePath by remember(showWebDavConfigDialog) { + mutableStateOf(storage.getBasePath()) + } + + LaunchedEffect(webDavState.isConfigured, webDavState.successMessage) { + if (webDavState.isConfigured && webDavState.successMessage != null) { + onShowWebDavConfigDialogChange(false) + } + } + + AlertDialog( + onDismissRequest = { onShowWebDavConfigDialogChange(false) }, + title = { Text(stringResource(R.string.webdav_sync_title)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.webdav_sync_desc), + style = MaterialTheme.typography.bodyMedium + ) + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text(stringResource(R.string.webdav_server_url_label)) }, + placeholder = { Text(stringResource(R.string.webdav_server_url_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.webdav_username_label)) }, + placeholder = { Text(stringResource(R.string.webdav_username_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.webdav_password_label)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = basePath, + onValueChange = { basePath = it }, + label = { Text(stringResource(R.string.webdav_base_path_label)) }, + placeholder = { Text(stringResource(R.string.webdav_base_path_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.webdav_remote_file_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + HapticButton( + onClick = { + webDavVm.validateAndSaveConfiguration( + context = context, + serverUrl = serverUrl, + username = username, + password = password, + basePath = basePath + ) + }, + enabled = !webDavState.isValidating + ) { + if (webDavState.isValidating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } + Text(stringResource(R.string.webdav_validate_and_save)) + } + }, + dismissButton = { + HapticTextButton(onClick = { onShowWebDavConfigDialogChange(false) }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + + if (showClearWebDavConfigDialog) { + AlertDialog( + onDismissRequest = { onShowClearWebDavConfigDialogChange(false) }, + title = { Text(stringResource(R.string.sync_clear_config)) }, + text = { Text(stringResource(R.string.webdav_clear_config_desc)) }, + confirmButton = { + HapticTextButton( + onClick = { + webDavVm.clearConfiguration(context) + onShowClearWebDavConfigDialogChange(false) + } + ) { + Text( + stringResource(R.string.action_confirm_clear), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + HapticTextButton(onClick = { onShowClearWebDavConfigDialogChange(false) }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncViewModel.kt new file mode 100644 index 00000000..b29ba7bc --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncViewModel.kt @@ -0,0 +1,178 @@ +package moe.ouom.neriplayer.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.data.sync.webdav.WebDavApiClient +import moe.ouom.neriplayer.data.sync.webdav.WebDavAuthException +import moe.ouom.neriplayer.data.sync.webdav.WebDavStorage +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncInProgressException +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncManager +import moe.ouom.neriplayer.data.sync.webdav.WebDavSyncWorker +import moe.ouom.neriplayer.data.sync.github.SyncResult + +class WebDavSyncViewModel : ViewModel() { + private val _uiState = MutableStateFlow(WebDavSyncUiState()) + val uiState: StateFlow = _uiState + + private var storage: WebDavStorage? = null + private var syncManager: WebDavSyncManager? = null + + fun initialize(context: Context) { + if (storage == null) { + storage = WebDavStorage(context) + syncManager = WebDavSyncManager.getInstance(context) + loadConfiguration() + } + } + + private fun loadConfiguration() { + val store = storage ?: return + _uiState.value = _uiState.value.copy( + isConfigured = store.isConfigured(), + autoSyncEnabled = store.isAutoSyncEnabled(), + serverUrl = store.getServerUrl().orEmpty(), + basePath = store.getBasePath(), + username = store.getUsername().orEmpty(), + lastSyncTime = store.getLastSyncTime() + ) + } + + fun validateAndSaveConfiguration( + context: Context, + serverUrl: String, + username: String, + password: String, + basePath: String + ) { + val normalizedServerUrl = serverUrl.trim() + val normalizedUsername = username.trim() + val normalizedBasePath = basePath.trim() + if (normalizedServerUrl.isBlank() || normalizedUsername.isBlank() || password.isBlank()) { + _uiState.value = _uiState.value.copy( + errorMessage = context.getString(R.string.webdav_required_fields) + ) + return + } + + _uiState.value = _uiState.value.copy(isValidating = true, errorMessage = null) + + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + WebDavApiClient(context, normalizedUsername, password) + .validateConnection(normalizedServerUrl, normalizedBasePath) + } + + if (result.isSuccess) { + storage?.saveConfiguration( + serverUrl = normalizedServerUrl, + username = normalizedUsername, + password = password, + basePath = normalizedBasePath + ) + _uiState.value = _uiState.value.copy( + isConfigured = true, + isValidating = false, + serverUrl = normalizedServerUrl, + basePath = normalizedBasePath, + username = normalizedUsername, + successMessage = context.getString(R.string.webdav_validate_success) + ) + } else { + _uiState.value = _uiState.value.copy( + isValidating = false, + errorMessage = context.getString( + R.string.webdav_validate_failed, + result.exceptionOrNull()?.message + ?: context.getString(R.string.webdav_sync_failed_message) + ) + ) + } + } + } + + fun performSync(context: Context) { + _uiState.value = _uiState.value.copy(isSyncing = true, errorMessage = null, syncResult = null) + + viewModelScope.launch { + val manager = syncManager ?: return@launch + val result = manager.performSync() + if (result.isSuccess) { + val syncResult = result.getOrNull()!! + val lastSyncTime = storage?.getLastSyncTime() ?: _uiState.value.lastSyncTime + _uiState.value = _uiState.value.copy( + isSyncing = false, + syncResult = syncResult, + lastSyncTime = lastSyncTime, + successMessage = syncResult.message + ) + if (_uiState.value.autoSyncEnabled) { + WebDavSyncWorker.schedulePeriodicSync(context) + } + } else { + val error = result.exceptionOrNull() + if (error is WebDavSyncInProgressException) { + _uiState.value = _uiState.value.copy( + isSyncing = false, + successMessage = error.message + ) + return@launch + } + if (error is WebDavAuthException) { + _uiState.value = _uiState.value.copy( + isSyncing = false, + errorMessage = context.getString(R.string.webdav_auth_failed) + ) + } else { + _uiState.value = _uiState.value.copy( + isSyncing = false, + errorMessage = context.getString( + R.string.webdav_sync_failed, + error?.message ?: context.getString(R.string.webdav_sync_failed_message) + ) + ) + } + } + } + } + + fun toggleAutoSync(context: Context, enabled: Boolean) { + storage?.setAutoSyncEnabled(enabled) + _uiState.value = _uiState.value.copy(autoSyncEnabled = enabled) + if (enabled) { + WebDavSyncWorker.schedulePeriodicSync(context) + } else { + WebDavSyncWorker.cancelAllSync(context) + } + } + + fun clearConfiguration(context: Context) { + storage?.clearAll() + WebDavSyncWorker.cancelAllSync(context) + _uiState.value = WebDavSyncUiState() + } + + fun clearMessages() { + _uiState.value = _uiState.value.copy(successMessage = null, errorMessage = null) + } +} + +data class WebDavSyncUiState( + val isConfigured: Boolean = false, + val isValidating: Boolean = false, + val isSyncing: Boolean = false, + val autoSyncEnabled: Boolean = false, + val serverUrl: String = "", + val basePath: String = "", + val username: String = "", + val lastSyncTime: Long = 0L, + val syncResult: SyncResult? = null, + val successMessage: String? = null, + val errorMessage: String? = null +) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 79f760c6..5eb19f0d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -777,6 +777,33 @@ GitHub Token expired or invalid, please reconfigure Remote backup file is invalid or corrupted Sync failed: %s + WebDAV Sync + Configure your WebDAV server, username, password, and initial path + Auto sync changes to WebDAV + Server URL + https://example.com/dav + Username + your-username + Password + Initial Path + backup/neriplayer + The file will be saved as a file named neriplayer-sync.json and appended automatically. + Validate & Save + WebDAV configured successfully + WebDAV configuration failed: %s + Please enter the server URL, username, and password + WebDAV username or password is invalid + WebDAV sync failed: %s + WebDAV sync failed + WebDAV is not configured + Remote WebDAV backup file is invalid or corrupted + WebDAV sync in progress, skipped + WebDAV sync successful (no changes) + WebDAV sync successful + Clear all WebDAV sync settings. Local data will not be deleted. + WebDAV Sync + WebDAV sync status notifications + WebDAV Sync Failed Unknown Song diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00cda461..662339c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -777,6 +777,33 @@ GitHub Token 已过期或无效,请重新配置 远端备份文件无效或已损坏 同步失败: %s + WebDAV 同步 + 配置 WebDAV 服务器、用户名、密码和初始路径 + 修改后自动同步到 WebDAV + 服务器 URL + https://example.com/dav + 用户名 + your-username + 密码 + 初始路径 + backup/neriplayer + 文件将保存为名为 neriplayer-sync.json 的文件,程序会自动拼接到上面的目录下。 + 验证并保存 + WebDAV 配置成功 + WebDAV 配置失败: %s + 请完整填写服务器 URL、用户名和密码 + WebDAV 用户名或密码错误 + WebDAV 同步失败: %s + WebDAV 同步失败 + WebDAV 未配置 + WebDAV 远端备份文件无效或已损坏 + WebDAV 同步正在进行中,已跳过 + WebDAV 同步成功(无变化) + WebDAV 同步成功 + 这将清除所有 WebDAV 同步配置。本地数据不会被删除。 + WebDAV同步 + WebDAV同步状态通知 + WebDAV 同步失败 未知歌曲 From d10f9ddfe4259e1d9688e7ff0739f30f12c9469c Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Fri, 27 Mar 2026 17:20:29 +0800 Subject: [PATCH 04/11] fix(player): prevent inactive playback service restart and add lifecycle logs --- .../ouom/neriplayer/activity/MainActivity.kt | 8 +- .../core/player/AudioPlayerService.kt | 77 ++++++++++++++----- .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 27 ++++--- 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt index 9e43b158..373f9516 100644 --- a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt +++ b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt @@ -775,11 +775,13 @@ class MainActivity : ComponentActivity() { if (result.songs.isNotEmpty()) { PlayerManager.initialize(application) PlayerManager.playPlaylist(result.songs, startIndex = 0) + NPLogger.d("MainActivity", "Starting audio service after external audio import") ContextCompat.startForegroundService( this@MainActivity, - Intent(this@MainActivity, AudioPlayerService::class.java).apply { - setAction(AudioPlayerService.ACTION_SYNC) - } + AudioPlayerService.createSyncIntent( + this@MainActivity, + "external_audio_import" + ) ) } } catch (_: CancellationException) { diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt index 4bda5577..535c7c61 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt @@ -113,9 +113,17 @@ class AudioPlayerService : Service() { const val ACTION_PREV = "moe.ouom.neriplayer.action.PREV" const val ACTION_SYNC = "moe.ouom.neriplayer.action.SYNC" const val ACTION_TOGGLE_FAV = "moe.ouom.neriplayer.action.TOGGLE_FAVORITE" + const val EXTRA_START_SOURCE = "audio_service_start_source" private const val NOTIFICATION_ID = 1 private const val CHANNEL_ID = "neriplayer_playback_channel" + + fun createSyncIntent(context: Context, source: String): Intent { + return Intent(context, AudioPlayerService::class.java).apply { + action = ACTION_SYNC + putExtra(EXTRA_START_SOURCE, source) + } + } } private lateinit var becomingNoisyReceiver: BroadcastReceiver @@ -131,6 +139,16 @@ class AudioPlayerService : Service() { private var isForegroundStarted = false private var lastNotificationSnapshot: PlaybackNotificationSnapshot? = null + private fun shouldKeepServiceSticky(): Boolean { + return PlayerManager.hasItems() && PlayerManager.isTransportActive() + } + + private fun buildStateSummary(): String { + return "hasItems=${PlayerManager.hasItems()} currentSong=${PlayerManager.currentSongFlow.value != null} " + + "isPlaying=${PlayerManager.isPlayingFlow.value} transportActive=${PlayerManager.isTransportActive()} " + + "foreground=$isForegroundStarted allowRestart=$allowServiceRestart" + } + private val mediaSessionCallback = object : MediaSessionCompat.Callback() { override fun onPlay() { PlayerManager.play(); updateAll() } override fun onPause() { handleExternalPauseCommand("media_session_pause") } @@ -173,13 +191,14 @@ class AudioPlayerService : Service() { } if (shouldStopService) { allowServiceRestart = false - stopForegroundIfStarted() + stopForegroundIfStarted("external_pause_command:$source") stopSelf() } } override fun onCreate() { super.onCreate() + NPLogger.d("NERI-APS", "onCreate begin ${buildStateSummary()}") val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( CHANNEL_ID, @@ -192,7 +211,7 @@ class AudioPlayerService : Service() { setCallback(mediaSessionCallback) isActive = true } - startForegroundImmediately(buildBootstrapNotification()) + startForegroundImmediately(buildBootstrapNotification(), "service_create") // 服务必须尽快进入前台,不能在这里阻塞前台通知启动 PlayerManager.initialize(application as Application) @@ -203,7 +222,8 @@ class AudioPlayerService : Service() { if (!hasReceivedStartCommand) { return@collect } - stopForegroundIfStarted() + NPLogger.w("NERI-APS", "currentSongFlow requested self-stop because playlist is empty") + stopForegroundIfStarted("playlist_became_empty") stopSelf() return@collect } @@ -267,17 +287,22 @@ class AudioPlayerService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - NPLogger.d("NERI-APS", "onStartCommand action=${intent?.action}") + val action = intent?.action + val startSource = intent?.getStringExtra(EXTRA_START_SOURCE) ?: "unspecified" + NPLogger.d( + "NERI-APS", + "onStartCommand action=$action source=$startSource flags=$flags startId=$startId ${buildStateSummary()}" + ) allowServiceRestart = true hasReceivedStartCommand = true - val action = intent?.action if (!isForegroundStarted && action != ACTION_STOP) { - startForegroundImmediately(buildBootstrapNotification()) + startForegroundImmediately(buildBootstrapNotification(), "on_start_command:$action:$startSource") } if (action == null && !PlayerManager.hasItems()) { allowServiceRestart = false - stopForegroundIfStarted() + NPLogger.w("NERI-APS", "Stopping service because null action arrived without playlist") + stopForegroundIfStarted("null_action_without_items") stopSelf() return START_NOT_STICKY } @@ -320,10 +345,12 @@ class AudioPlayerService : Service() { ACTION_SYNC -> { if (!PlayerManager.hasItems()) { allowServiceRestart = false - stopForegroundIfStarted() + NPLogger.w("NERI-APS", "Ignoring ACTION_SYNC because playlist is empty, source=$startSource") + stopForegroundIfStarted("sync_without_items") stopSelf() return START_NOT_STICKY } + NPLogger.d("NERI-APS", "Handling ACTION_SYNC source=$startSource ${buildStateSummary()}") updateAll() } @@ -355,12 +382,22 @@ class AudioPlayerService : Service() { } } else { allowServiceRestart = false - stopForegroundIfStarted() + NPLogger.w("NERI-APS", "Stopping service because playlist is empty after action handling") + stopForegroundIfStarted("no_items_after_action") stopSelf() return START_NOT_STICKY } - return if (allowServiceRestart) START_STICKY else START_NOT_STICKY + val startMode = if (allowServiceRestart && shouldKeepServiceSticky()) { + START_STICKY + } else { + START_NOT_STICKY + } + NPLogger.d( + "NERI-APS", + "onStartCommand complete action=$action source=$startSource startMode=$startMode ${buildStateSummary()}" + ) + return startMode } private fun buildNotification(): Notification { @@ -716,7 +753,7 @@ class AudioPlayerService : Service() { override fun onDestroy() { NPLogger.w( "NERI-APS", - "onDestroy allowServiceRestart=$allowServiceRestart hasItems=${PlayerManager.hasItems()} isPlaying=${PlayerManager.isPlayingFlow.value}" + "onDestroy ${buildStateSummary()}" ) unregisterReceiver(becomingNoisyReceiver) serviceScope.cancel() @@ -732,7 +769,7 @@ class AudioPlayerService : Service() { super.onTrimMemory(level) NPLogger.w( "NERI-APS", - "onTrimMemory level=$level foreground=$isForegroundStarted hasItems=${PlayerManager.hasItems()} isPlaying=${PlayerManager.isPlayingFlow.value}" + "onTrimMemory level=$level ${buildStateSummary()}" ) } @@ -740,7 +777,7 @@ class AudioPlayerService : Service() { super.onLowMemory() NPLogger.w( "NERI-APS", - "onLowMemory foreground=$isForegroundStarted hasItems=${PlayerManager.hasItems()} isPlaying=${PlayerManager.isPlayingFlow.value}" + "onLowMemory ${buildStateSummary()}" ) } @@ -751,11 +788,13 @@ class AudioPlayerService : Service() { return true } val notification = buildNotification() - return startForegroundImmediately(notification) + NPLogger.d("NERI-APS", "ensureForegroundStarted requested ${buildStateSummary()}") + return startForegroundImmediately(notification, "ensure_foreground") } - private fun startForegroundImmediately(notification: Notification): Boolean { + private fun startForegroundImmediately(notification: Notification, reason: String): Boolean { return try { + NPLogger.d("NERI-APS", "startForegroundImmediately reason=$reason ${buildStateSummary()}") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, @@ -766,13 +805,14 @@ class AudioPlayerService : Service() { startForeground(NOTIFICATION_ID, notification) } isForegroundStarted = true + NPLogger.d("NERI-APS", "startForegroundImmediately success reason=$reason") true } catch (e: SecurityException) { - NPLogger.e("NERI-APS", "Failed to start foreground service", e) + NPLogger.e("NERI-APS", "Failed to start foreground service, reason=$reason", e) false } catch (e: RuntimeException) { if (isForegroundStartNotAllowed(e)) { - NPLogger.w("NERI-APS", "startForeground not allowed right now: ${e.message}") + NPLogger.w("NERI-APS", "startForeground not allowed right now, reason=$reason: ${e.message}") false } else { throw e @@ -785,10 +825,11 @@ class AudioPlayerService : Service() { error.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException" } - private fun stopForegroundIfStarted() { + private fun stopForegroundIfStarted(reason: String) { if (!isForegroundStarted) { return } + NPLogger.w("NERI-APS", "stopForegroundIfStarted reason=$reason ${buildStateSummary()}") stopForeground(STOP_FOREGROUND_REMOVE) isForegroundStarted = false } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt index f96995d8..4398c0fb 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt @@ -610,14 +610,18 @@ fun NeriApp( val cacheSize = repo.maxCacheSizeBytesFlow.first() PlayerManager.initialize(context.applicationContext as Application, cacheSize) NPLogger.d("NERI-App", "PlayerManager.initialize called") - NPLogger.d("PlayerManager.hasItems()", PlayerManager.hasItems().toString()) - if (PlayerManager.hasItems()) { + NPLogger.d( + "NERI-App", + "Player bootstrap state hasItems=${PlayerManager.hasItems()} transportActive=${PlayerManager.isTransportActive()} isPlaying=${PlayerManager.isPlayingFlow.value}" + ) + if (PlayerManager.hasItems() && PlayerManager.isTransportActive()) { + NPLogger.d("NERI-App", "Starting audio service from app bootstrap") ContextCompat.startForegroundService( context, - Intent(context, AudioPlayerService::class.java).apply { - action = AudioPlayerService.ACTION_SYNC - } + AudioPlayerService.createSyncIntent(context, "app_bootstrap") ) + } else { + NPLogger.d("NERI-App", "Skip audio service bootstrap because transport is inactive") } } @@ -717,20 +721,21 @@ fun NeriApp( showNowPlaying = true // 播放队列可能包含歌词等大字段,避免通过 Binder 传整份歌单导致崩溃 PlayerManager.playPlaylist(songs, index) + NPLogger.d("NERI-App", "Starting audio service after playSongsAndOpenNowPlaying") ContextCompat.startForegroundService( context, - Intent(context, AudioPlayerService::class.java).apply { - action = AudioPlayerService.ACTION_SYNC - } + AudioPlayerService.createSyncIntent(context, "play_songs_and_open_now_playing") ) } fun ensureAudioServiceStarted() { + NPLogger.d( + "NERI-App", + "ensureAudioServiceStarted hasItems=${PlayerManager.hasItems()} transportActive=${PlayerManager.isTransportActive()} isPlaying=${PlayerManager.isPlayingFlow.value}" + ) ContextCompat.startForegroundService( context, - Intent(context, AudioPlayerService::class.java).apply { - action = AudioPlayerService.ACTION_SYNC - } + AudioPlayerService.createSyncIntent(context, "ensure_audio_service_started") ) } From ca2ef53d34e655664c79105e20a4cb359967c835 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 28 Mar 2026 00:47:04 +0800 Subject: [PATCH 05/11] feat(download): support configurable file naming and metadata-first managed downloads --- .../ouom/neriplayer/core/di/AppContainer.kt | 27 ++- .../core/download/GlobalDownloadManager.kt | 134 ++++++++++--- .../core/download/ManagedDownloadNaming.kt | 86 +++++++- .../core/download/ManagedDownloadStorage.kt | 186 ++++++++++++++++-- .../core/player/AudioDownloadManager.kt | 59 +++--- .../data/settings/SettingsRepository.kt | 17 ++ .../neriplayer/data/settings/SettingsStore.kt | 1 + .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 5 + .../ui/screen/host/SettingsHostScreen.kt | 4 + .../ui/screen/tab/SettingsScreen.kt | 4 + .../component/SettingsStorageCacheSection.kt | 157 +++++++++++++++ app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../core/di/AppContainerBootstrapTest.kt | 18 +- .../download/ManagedDownloadNamingTest.kt | 90 +++++++++ 15 files changed, 719 insertions(+), 77 deletions(-) create mode 100644 app/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadNamingTest.kt diff --git a/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt b/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt index 266882b3..b5749695 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt @@ -72,22 +72,27 @@ internal fun resolveInitialBypassProxy( internal data class InitialManagedDownloadSettings( val directoryUri: String? = null, - val directoryLabel: String? = null + val directoryLabel: String? = null, + val fileNameTemplate: String? = null ) internal fun resolveInitialManagedDownloadSettings( currentDirectoryUri: String? = null, currentDirectoryLabel: String? = null, + currentFileNameTemplate: String? = null, loadDirectoryUri: () -> String?, - loadDirectoryLabel: () -> String? + loadDirectoryLabel: () -> String?, + loadFileNameTemplate: () -> String? ): InitialManagedDownloadSettings { return InitialManagedDownloadSettings( directoryUri = runCatching(loadDirectoryUri).getOrDefault(currentDirectoryUri), - directoryLabel = runCatching(loadDirectoryLabel).getOrDefault(currentDirectoryLabel) + directoryLabel = runCatching(loadDirectoryLabel).getOrDefault(currentDirectoryLabel), + fileNameTemplate = runCatching(loadFileNameTemplate).getOrDefault(currentFileNameTemplate) ).let { resolved -> InitialManagedDownloadSettings( directoryUri = resolved.directoryUri?.takeIf(String::isNotBlank), - directoryLabel = resolved.directoryLabel?.takeIf(String::isNotBlank) + directoryLabel = resolved.directoryLabel?.takeIf(String::isNotBlank), + fileNameTemplate = resolved.fileNameTemplate?.takeIf(String::isNotBlank) ) } } @@ -273,11 +278,17 @@ object AppContainer { runBlocking { settingsRepo.downloadDirectoryLabelFlow.first() } + }, + loadFileNameTemplate = { + runBlocking { + settingsRepo.downloadFileNameTemplateFlow.first() + } } ) ManagedDownloadStorage.primeSettings( directoryUri = initialManagedDownloadSettings.directoryUri, - directoryLabel = initialManagedDownloadSettings.directoryLabel + directoryLabel = initialManagedDownloadSettings.directoryLabel, + fileNameTemplate = initialManagedDownloadSettings.fileNameTemplate ) } @@ -327,6 +338,12 @@ object AppContainer { ManagedDownloadStorage.updateCustomDirectoryLabel(label) } .launchIn(scope) + + settingsRepo.downloadFileNameTemplateFlow + .onEach { template -> + ManagedDownloadStorage.updateDownloadFileNameTemplate(template) + } + .launchIn(scope) } private fun isYouTubeHost(host: String): Boolean { diff --git a/app/src/main/java/moe/ouom/neriplayer/core/download/GlobalDownloadManager.kt b/app/src/main/java/moe/ouom/neriplayer/core/download/GlobalDownloadManager.kt index 4ad09835..987af698 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/download/GlobalDownloadManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/download/GlobalDownloadManager.kt @@ -46,6 +46,7 @@ import moe.ouom.neriplayer.core.player.AudioDownloadManager import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.model.identity import moe.ouom.neriplayer.data.model.stableKey import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.util.NPLogger @@ -276,11 +277,19 @@ object GlobalDownloadManager { audio = storedAudio, metadataEntry = effectiveSnapshot.metadataEntriesByAudioName[storedAudio.name] ) + val localDetails = if (metadata == null) { + inspectDownloadedAudioDetails(context, storedAudio) + } else { + null + } val (parsedArtist, parsedTitle) = parseDownloadedFileName(storedAudio.name) val coverReference = metadata?.coverPath ?.takeIf { it in effectiveSnapshot.knownReferences || ManagedDownloadStorage.exists(context, it) } + ?: localDetails?.coverUri ?: findIndexedCoverReference(storedAudio, effectiveSnapshot) - val matchedLyric = metadata?.matchedLyric ?: if (resolveLyricFallbacks) { + val matchedLyric = metadata?.lyricPath?.let { ManagedDownloadStorage.readText(context, it) } + ?: localDetails?.lyricContent + ?: if (resolveLyricFallbacks) { findIndexedLyricText( context = context, audio = storedAudio, @@ -291,7 +300,7 @@ object GlobalDownloadManager { } else { null } - val matchedTranslatedLyric = metadata?.matchedTranslatedLyric ?: if (resolveLyricFallbacks) { + val matchedTranslatedLyric = metadata?.translatedLyricPath?.let { ManagedDownloadStorage.readText(context, it) } ?: if (resolveLyricFallbacks) { findIndexedLyricText( context = context, audio = storedAudio, @@ -305,9 +314,9 @@ object GlobalDownloadManager { return DownloadedSong( id = metadata?.songId ?: storedAudio.reference.hashCode().toLong(), - name = metadata?.name?.takeIf(String::isNotBlank) ?: parsedTitle, - artist = metadata?.artist?.takeIf(String::isNotBlank) ?: parsedArtist, - album = context.getString(R.string.local_files), + name = metadata?.name?.takeIf(String::isNotBlank) ?: localDetails?.title?.takeIf(String::isNotBlank) ?: parsedTitle, + artist = metadata?.artist?.takeIf(String::isNotBlank) ?: localDetails?.artist?.takeIf(String::isNotBlank) ?: parsedArtist, + album = localDetails?.album?.takeIf(String::isNotBlank) ?: context.getString(R.string.local_files), filePath = storedAudio.reference, fileSize = storedAudio.sizeBytes, downloadTime = existingDownloadTime ?: storedAudio.lastModifiedMs, @@ -321,13 +330,13 @@ object GlobalDownloadManager { customCoverUrl = metadata?.customCoverUrl, customName = metadata?.customName, customArtist = metadata?.customArtist, - originalName = metadata?.originalName, - originalArtist = metadata?.originalArtist, + originalName = metadata?.originalName ?: localDetails?.originalTitle, + originalArtist = metadata?.originalArtist ?: localDetails?.originalArtist, originalCoverUrl = metadata?.originalCoverUrl, originalLyric = metadata?.originalLyric, originalTranslatedLyric = metadata?.originalTranslatedLyric, mediaUri = storedAudio.playbackUri, - durationMs = metadata?.durationMs ?: 0L + durationMs = metadata?.durationMs?.takeIf { it > 0L } ?: localDetails?.durationMs ?: 0L ) } @@ -346,9 +355,24 @@ object GlobalDownloadManager { audio: ManagedDownloadStorage.StoredEntry, song: SongItem ) { + val identity = song.identity() val coverReference = ManagedDownloadStorage.findCoverReference(context, audio) + val lyricPath = ManagedDownloadStorage.findLyricLocation( + context = context, + songId = song.id, + candidateBaseNames = candidateManagedDownloadBaseNames(audio.nameWithoutExtension), + translated = false + ) + val translatedLyricPath = ManagedDownloadStorage.findLyricLocation( + context = context, + songId = song.id, + candidateBaseNames = candidateManagedDownloadBaseNames(audio.nameWithoutExtension), + translated = true + ) val payload = JSONObject().apply { + put("stableKey", identity.stableKey()) put("songId", song.id) + put("identityAlbum", identity.album) put("name", song.name) put("artist", song.artist) put("coverUrl", song.coverUrl) @@ -365,13 +389,23 @@ object GlobalDownloadManager { put("originalCoverUrl", song.originalCoverUrl) put("originalLyric", song.originalLyric) put("originalTranslatedLyric", song.originalTranslatedLyric) - put("mediaUri", song.mediaUri) + put("mediaUri", identity.mediaUri ?: song.mediaUri) + put("channelId", song.channelId) + put("audioId", song.audioId) + put("subAudioId", song.subAudioId) + put("playlistContextId", song.playlistContextId) put("coverPath", coverReference) + put("lyricPath", lyricPath) + put("translatedLyricPath", translatedLyricPath) put("durationMs", song.durationMs) } runCatching { ManagedDownloadStorage.saveMetadata(context, audio, payload.toString()) + NPLogger.d( + TAG, + "保存下载 metadata: file=${audio.name}, stableKey=${identity.stableKey()}, lyricPath=$lyricPath, translatedLyricPath=$translatedLyricPath, coverPath=$coverReference" + ) }.onFailure { error -> NPLogger.w(TAG, "写入下载元数据失败: ${audio.name} - ${error.message}") } @@ -390,7 +424,9 @@ object GlobalDownloadManager { return runCatching { val root = JSONObject(raw) DownloadedSongMetadata( + stableKey = root.optString("stableKey").takeIf(String::isNotBlank), songId = root.optLong("songId").takeIf { it > 0L }, + identityAlbum = root.optString("identityAlbum").takeIf(String::isNotBlank), name = root.optString("name").takeIf(String::isNotBlank), artist = root.optString("artist").takeIf(String::isNotBlank), coverUrl = root.optString("coverUrl").takeIf(String::isNotBlank), @@ -408,7 +444,12 @@ object GlobalDownloadManager { originalLyric = null, originalTranslatedLyric = null, coverPath = root.optString("coverPath").takeIf(String::isNotBlank), + lyricPath = root.optString("lyricPath").takeIf(String::isNotBlank), + translatedLyricPath = root.optString("translatedLyricPath").takeIf(String::isNotBlank), mediaUri = root.optString("mediaUri").takeIf(String::isNotBlank), + channelId = root.optString("channelId").takeIf(String::isNotBlank), + audioId = root.optString("audioId").takeIf(String::isNotBlank), + subAudioId = root.optString("subAudioId").takeIf(String::isNotBlank), durationMs = root.optLong("durationMs") ) }.getOrElse { error -> @@ -455,34 +496,51 @@ object GlobalDownloadManager { scope.launch { try { val storedAudio = resolveStoredAudio(appContext, song.filePath) + val snapshot = ManagedDownloadStorage.buildDownloadLibrarySnapshot(appContext) val baseNames = candidateBaseNames(song, storedAudio?.nameWithoutExtension) val metadataReference = storedAudio?.let { ManagedDownloadStorage.findMetadataForAudio(appContext, it)?.reference } + val metadata = storedAudio?.let { + readDownloadedMetadata(appContext, it) + } + val currentAudioName = storedAudio?.name val lyricReferences = buildList { - ManagedDownloadStorage.findLyricLocation( - context = appContext, - songId = song.id, - candidateBaseNames = baseNames, - translated = false - )?.let(::add) - ManagedDownloadStorage.findLyricLocation( - context = appContext, - songId = song.id, - candidateBaseNames = baseNames, - translated = true - )?.let(::add) + metadata?.lyricPath?.let(::add) + metadata?.translatedLyricPath?.let(::add) + if (isEmpty()) { + ManagedDownloadStorage.findLyricLocation( + context = appContext, + songId = metadata?.songId ?: song.id, + candidateBaseNames = baseNames, + translated = false + )?.let(::add) + ManagedDownloadStorage.findLyricLocation( + context = appContext, + songId = metadata?.songId ?: song.id, + candidateBaseNames = baseNames, + translated = true + )?.let(::add) + } } storedAudio?.let { ManagedDownloadStorage.deleteReference(appContext, it.reference) } ?: ManagedDownloadStorage.deleteReference(appContext, song.filePath) - listOfNotNull(song.coverPath, metadataReference) + listOfNotNull(metadata?.coverPath, song.coverPath, metadataReference) .plus(lyricReferences) .distinct() .forEach { reference -> - ManagedDownloadStorage.deleteReference(appContext, reference) + if (metadataReference != null && reference == metadataReference) { + NPLogger.d(TAG, "删除下载关联文件: song=${song.name}, reference=$reference") + ManagedDownloadStorage.deleteReference(appContext, reference) + } else if (isReferenceOwnedByOtherDownload(snapshot, currentAudioName, reference)) { + NPLogger.w(TAG, "跳过删除共享关联文件: song=${song.name}, reference=$reference") + } else { + NPLogger.d(TAG, "删除下载关联文件: song=${song.name}, reference=$reference") + ManagedDownloadStorage.deleteReference(appContext, reference) + } } reloadDownloadedSongs(appContext) @@ -778,10 +836,35 @@ object GlobalDownloadManager { else -> null } } + + private fun inspectDownloadedAudioDetails( + context: Context, + storedAudio: ManagedDownloadStorage.StoredEntry + ) = runCatching { + LocalMediaSupport.inspect(context, storedAudio.playbackUri.toUri()) + }.onFailure { error -> + NPLogger.w(TAG, "读取已下载音频标签失败: ${storedAudio.name} - ${error.message}") + }.getOrNull() + + private fun isReferenceOwnedByOtherDownload( + snapshot: ManagedDownloadStorage.DownloadLibrarySnapshot, + currentAudioName: String?, + reference: String + ): Boolean { + return snapshot.metadataByAudioName.any { (audioName, metadata) -> + audioName != currentAudioName && listOfNotNull( + metadata.coverPath, + metadata.lyricPath, + metadata.translatedLyricPath + ).contains(reference) + } + } } private data class DownloadedSongMetadata( + val stableKey: String? = null, val songId: Long? = null, + val identityAlbum: String? = null, val name: String? = null, val artist: String? = null, val coverUrl: String? = null, @@ -799,7 +882,12 @@ private data class DownloadedSongMetadata( val originalLyric: String? = null, val originalTranslatedLyric: String? = null, val coverPath: String? = null, + val lyricPath: String? = null, + val translatedLyricPath: String? = null, val mediaUri: String? = null, + val channelId: String? = null, + val audioId: String? = null, + val subAudioId: String? = null, val durationMs: Long = 0L ) diff --git a/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadNaming.kt b/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadNaming.kt index 2823e559..32f5dc8e 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadNaming.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadNaming.kt @@ -3,18 +3,88 @@ package moe.ouom.neriplayer.core.download import java.text.Normalizer import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +internal const val DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE = "%source% - %artist% - %title%" + internal fun sanitizeManagedDownloadFileName(name: String): String { val normalized = Normalizer.normalize(name, Normalizer.Form.NFKD) return normalized.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().ifBlank { "audio" } } -internal fun candidateManagedDownloadBaseNames(song: SongItem): List { +internal fun normalizeDownloadFileNameTemplate(template: String?): String? { + return template?.trim()?.takeIf { it.isNotEmpty() } +} + +internal fun renderManagedDownloadBaseName( + title: String, + artist: String, + album: String, + source: String = "", + songId: String = "", + audioId: String = "", + subAudioId: String = "", + template: String? = DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE +): String { + val effectiveTemplate = normalizeDownloadFileNameTemplate(template) ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + val rendered = effectiveTemplate + .replace("%title%", title) + .replace("%artist%", artist) + .replace("%album%", album) + .replace("%source%", source) + .replace("%id%", songId) + .replace("%audioId%", audioId) + .replace("%subAudioId%", subAudioId) + return sanitizeManagedDownloadFileName(rendered) +} + +internal fun renderManagedDownloadBaseName( + song: SongItem, + template: String? = DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE +): String { + return renderManagedDownloadBaseName( + title = song.customName ?: song.name, + artist = song.customArtist ?: song.artist, + album = song.album, + source = managedDownloadSource(song), + songId = song.id.toString(), + audioId = song.audioId.orEmpty(), + subAudioId = song.subAudioId.orEmpty(), + template = template + ) +} + +internal fun candidateManagedDownloadBaseNames( + song: SongItem, + activeTemplate: String? = null +): List { val baseNames = linkedSetOf() - baseNames += sanitizeManagedDownloadFileName("${song.customArtist ?: song.artist} - ${song.customName ?: song.name}") - baseNames += sanitizeManagedDownloadFileName("${song.artist} - ${song.name}") + baseNames += renderManagedDownloadBaseName(song, activeTemplate) + baseNames += renderManagedDownloadBaseName( + title = song.name, + artist = song.artist, + album = song.album, + source = managedDownloadSource(song), + songId = song.id.toString(), + audioId = song.audioId.orEmpty(), + subAudioId = song.subAudioId.orEmpty(), + template = activeTemplate + ) val originalName = song.originalName?.takeIf { it.isNotBlank() } ?: song.name val originalArtist = song.originalArtist?.takeIf { it.isNotBlank() } ?: song.artist + baseNames += renderManagedDownloadBaseName( + title = originalName, + artist = originalArtist, + album = song.album, + source = managedDownloadSource(song), + songId = song.id.toString(), + audioId = song.audioId.orEmpty(), + subAudioId = song.subAudioId.orEmpty(), + template = activeTemplate + ) + + // Keep matching historical downloads created before custom templates were introduced. + baseNames += sanitizeManagedDownloadFileName("${song.customArtist ?: song.artist} - ${song.customName ?: song.name}") + baseNames += sanitizeManagedDownloadFileName("${song.artist} - ${song.name}") baseNames += sanitizeManagedDownloadFileName("$originalArtist - $originalName") return baseNames.toList() @@ -28,3 +98,13 @@ internal fun candidateManagedDownloadBaseNames(fileNameWithoutExtension: String) } return names.toList() } + +private fun managedDownloadSource(song: SongItem): String { + return song.channelId + ?.takeIf { it.isNotBlank() } + ?: when { + song.album.startsWith("bili", ignoreCase = true) -> "bilibili" + song.mediaUri?.contains("youtube", ignoreCase = true) == true -> "youtube_music" + else -> "netease" + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadStorage.kt b/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadStorage.kt index 62fb5f15..d3ab32a3 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadStorage.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/download/ManagedDownloadStorage.kt @@ -13,8 +13,11 @@ import kotlinx.coroutines.withContext import moe.ouom.neriplayer.R import moe.ouom.neriplayer.data.model.displayArtist import moe.ouom.neriplayer.data.model.displayName +import moe.ouom.neriplayer.data.model.identity +import moe.ouom.neriplayer.data.model.stableKey import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem import moe.ouom.neriplayer.util.NPLogger +import org.json.JSONObject internal object ManagedDownloadStorage { private const val TAG = "ManagedDownloadStorage" @@ -30,6 +33,9 @@ internal object ManagedDownloadStorage { @Volatile private var customDirectoryLabel: String? = null + @Volatile + private var downloadFileNameTemplate: String? = null + @Volatile private var snapshotCache: SnapshotCache? = null @@ -78,6 +84,12 @@ internal object ManagedDownloadStorage { val audioEntries: List, val audioEntriesByLookupKey: Map, val metadataEntriesByAudioName: Map, + val metadataByAudioName: Map, + val audioEntriesWithoutMetadata: List, + val audioEntriesByStableKey: Map>, + val audioEntriesBySongId: Map>, + val audioEntriesByMediaUri: Map>, + val audioEntriesByRemoteTrackKey: Map>, val coverEntriesByName: Map, val lyricEntriesByName: Map, val knownReferences: Set @@ -88,14 +100,27 @@ internal object ManagedDownloadStorage { val snapshot: DownloadLibrarySnapshot ) + data class DownloadedAudioMetadata( + val stableKey: String? = null, + val songId: Long? = null, + val mediaUri: String? = null, + val channelId: String? = null, + val audioId: String? = null, + val subAudioId: String? = null, + val coverPath: String? = null, + val lyricPath: String? = null, + val translatedLyricPath: String? = null + ) + private sealed interface RootHandle { data class FileRoot(val dir: File) : RootHandle data class TreeRoot(val tree: DocumentFile) : RootHandle } - fun primeSettings(directoryUri: String?, directoryLabel: String?) { + fun primeSettings(directoryUri: String?, directoryLabel: String?, fileNameTemplate: String? = null) { customDirectoryUri = directoryUri?.takeIf { it.isNotBlank() } customDirectoryLabel = directoryLabel?.takeIf { it.isNotBlank() } + downloadFileNameTemplate = normalizeDownloadFileNameTemplate(fileNameTemplate) invalidateSnapshotCache() } @@ -112,6 +137,10 @@ internal object ManagedDownloadStorage { customDirectoryLabel = label?.takeIf { it.isNotBlank() } } + fun updateDownloadFileNameTemplate(template: String?) { + downloadFileNameTemplate = normalizeDownloadFileNameTemplate(template) + } + fun describeConfiguredDirectory(context: Context, uriString: String? = customDirectoryUri): String { val resolvedUri = uriString?.takeIf { it.isNotBlank() } if (resolvedUri.isNullOrBlank()) { @@ -233,7 +262,7 @@ internal object ManagedDownloadStorage { } fun buildDisplayBaseName(song: SongItem): String { - return sanitizeManagedDownloadFileName("${song.displayArtist()} - ${song.displayName()}") + return renderManagedDownloadBaseName(song, downloadFileNameTemplate) } fun createWorkingFile(context: Context, fileName: String): File { @@ -247,7 +276,7 @@ internal object ManagedDownloadStorage { fun peekDownloadedAudio(song: SongItem): StoredEntry? { return snapshotCache?.snapshot?.let { snapshot -> - findAudioEntry(snapshot.audioEntries, song) + findAudioEntry(snapshot, song) } } @@ -260,7 +289,7 @@ internal object ManagedDownloadStorage { } fun buildCandidateBaseNames(song: SongItem): List { - return candidateManagedDownloadBaseNames(song) + return candidateManagedDownloadBaseNames(song, downloadFileNameTemplate) } suspend fun findDownloadedAudio(context: Context, song: SongItem): StoredEntry? = withContext(Dispatchers.IO) { @@ -285,7 +314,7 @@ internal object ManagedDownloadStorage { private fun findDownloadedAudioBlocking(context: Context, song: SongItem): StoredEntry? { val snapshot = buildDownloadLibrarySnapshotBlocking(context) - return findAudioEntry(snapshot.audioEntries, song) + return findAudioEntry(snapshot, song) } private fun buildDownloadLibrarySnapshotBlocking( @@ -303,8 +332,43 @@ internal object ManagedDownloadStorage { val rootEntries = listChildren(root).filterNot(StoredEntry::isDirectory) val audioEntries = rootEntries.filter { it.extension in audioExtensions } val metadataEntries = rootEntries.filter { it.name.endsWith(METADATA_SUFFIX) } + val metadataByAudioName = metadataEntries.mapNotNull { entry -> + parseDownloadedAudioMetadata(context, entry)?.let { metadata -> + entry.name.removeSuffix(METADATA_SUFFIX) to metadata + } + }.toMap() val coverEntries = listSubdirectoryEntries(root, "Covers") val lyricEntries = listSubdirectoryEntries(root, "Lyrics") + val audioEntriesByStableKey = mutableMapOf>() + val audioEntriesBySongId = mutableMapOf>() + val audioEntriesByMediaUri = mutableMapOf>() + val audioEntriesByRemoteTrackKey = mutableMapOf>() + val audioEntriesWithoutMetadata = mutableListOf() + + audioEntries.forEach { entry -> + val metadata = metadataByAudioName[entry.name] + if (metadata == null) { + audioEntriesWithoutMetadata += entry + return@forEach + } + + metadata.stableKey?.let { key -> + audioEntriesByStableKey.getOrPut(key) { mutableListOf() } += entry + } + metadata.songId?.takeIf { it > 0L }?.let { songId -> + audioEntriesBySongId.getOrPut(songId) { mutableListOf() } += entry + } + metadata.mediaUri?.let { mediaUri -> + audioEntriesByMediaUri.getOrPut(mediaUri) { mutableListOf() } += entry + } + buildRemoteTrackKey( + channelId = metadata.channelId, + audioId = metadata.audioId, + subAudioId = metadata.subAudioId + )?.let { remoteTrackKey -> + audioEntriesByRemoteTrackKey.getOrPut(remoteTrackKey) { mutableListOf() } += entry + } + } return DownloadLibrarySnapshot( audioEntries = audioEntries, @@ -318,6 +382,12 @@ internal object ManagedDownloadStorage { metadataEntriesByAudioName = metadataEntries.associateBy { entry -> entry.name.removeSuffix(METADATA_SUFFIX) }, + metadataByAudioName = metadataByAudioName, + audioEntriesWithoutMetadata = audioEntriesWithoutMetadata, + audioEntriesByStableKey = audioEntriesByStableKey, + audioEntriesBySongId = audioEntriesBySongId, + audioEntriesByMediaUri = audioEntriesByMediaUri, + audioEntriesByRemoteTrackKey = audioEntriesByRemoteTrackKey, coverEntriesByName = coverEntries.associateBy(StoredEntry::name), lyricEntriesByName = lyricEntries.associateBy(StoredEntry::name), knownReferences = buildSet { @@ -460,13 +530,8 @@ internal object ManagedDownloadStorage { content: String, translated: Boolean ) { - val fileNameById = if (songId > 0L) { - if (translated) "${songId}_trans.lrc" else "${songId}.lrc" - } else { - null - } val fileNameByName = if (translated) "${baseName}_trans.lrc" else "$baseName.lrc" - fileNameById?.let { overwriteLyric(context, it, content) } + NPLogger.d(TAG, "写入歌词文件: fileName=$fileNameByName, translated=$translated, songId=$songId") overwriteLyric(context, fileNameByName, content) } @@ -474,7 +539,7 @@ internal object ManagedDownloadStorage { val reference = findLyricLocation( context = context, songId = song.id, - candidateBaseNames = candidateManagedDownloadBaseNames(song), + candidateBaseNames = candidateManagedDownloadBaseNames(song, downloadFileNameTemplate), translated = translated ) ?: return null return readTextInternal(context, reference) @@ -519,8 +584,54 @@ internal object ManagedDownloadStorage { } } - private fun findAudioEntry(audioEntries: List, song: SongItem): StoredEntry? { - return findAudioEntry(audioEntries, candidateManagedDownloadBaseNames(song)) + private fun findAudioEntry( + snapshot: DownloadLibrarySnapshot, + song: SongItem + ): StoredEntry? { + val identity = song.identity() + val stableKey = identity.stableKey() + val remoteTrackKey = buildRemoteTrackKey(song.channelId, song.audioId, song.subAudioId) + + snapshot.audioEntriesByStableKey[stableKey] + ?.let { matches -> + return pickBestAudioEntry(matches, song)?.also { entry -> + NPLogger.d(TAG, "命中已下载音频(stableKey): song=${song.displayName()}, file=${entry.name}") + } + } + + remoteTrackKey?.let { key -> + snapshot.audioEntriesByRemoteTrackKey[key] + ?.let { matches -> + return pickBestAudioEntry(matches, song)?.also { entry -> + NPLogger.d(TAG, "命中已下载音频(remoteTrackKey): song=${song.displayName()}, file=${entry.name}") + } + } + } + + identity.mediaUri?.let { mediaUri -> + snapshot.audioEntriesByMediaUri[mediaUri] + ?.let { matches -> + return pickBestAudioEntry(matches, song)?.also { entry -> + NPLogger.d(TAG, "命中已下载音频(mediaUri): song=${song.displayName()}, file=${entry.name}") + } + } + } + + identity.id.takeIf { it > 0L }?.let { songId -> + snapshot.audioEntriesBySongId[songId] + ?.let { matches -> + return pickBestAudioEntry(matches, song)?.also { entry -> + NPLogger.d(TAG, "命中已下载音频(songId): song=${song.displayName()}, file=${entry.name}") + } + } + } + + return findAudioEntry( + audioEntries = snapshot.audioEntriesWithoutMetadata, + baseNames = candidateManagedDownloadBaseNames(song, downloadFileNameTemplate) + )?.also { entry -> + NPLogger.d(TAG, "命中已下载音频(legacyNameFallback): song=${song.displayName()}, file=${entry.name}") + } } private fun findAudioEntry(audioEntries: List, baseNames: List): StoredEntry? { @@ -543,6 +654,16 @@ internal object ManagedDownloadStorage { } } + private fun pickBestAudioEntry( + audioEntries: List, + song: SongItem + ): StoredEntry? { + if (audioEntries.isEmpty()) return null + val baseNames = candidateManagedDownloadBaseNames(song, downloadFileNameTemplate) + return findAudioEntry(audioEntries, baseNames) + ?: audioEntries.maxByOrNull(StoredEntry::lastModifiedMs) + } + private fun listChildren(root: RootHandle): List { return when (root) { is RootHandle.FileRoot -> { @@ -619,6 +740,43 @@ internal object ManagedDownloadStorage { } } + private fun parseDownloadedAudioMetadata( + context: Context, + entry: StoredEntry + ): DownloadedAudioMetadata? { + val raw = readTextInternal(context, entry.reference) ?: return null + return runCatching { + val root = JSONObject(raw) + DownloadedAudioMetadata( + stableKey = root.optString("stableKey").takeIf(String::isNotBlank), + songId = root.optLong("songId").takeIf { it > 0L }, + mediaUri = root.optString("mediaUri").takeIf(String::isNotBlank), + channelId = root.optString("channelId").takeIf(String::isNotBlank), + audioId = root.optString("audioId").takeIf(String::isNotBlank), + subAudioId = root.optString("subAudioId").takeIf(String::isNotBlank), + coverPath = root.optString("coverPath").takeIf(String::isNotBlank), + lyricPath = root.optString("lyricPath").takeIf(String::isNotBlank), + translatedLyricPath = root.optString("translatedLyricPath").takeIf(String::isNotBlank) + ) + }.onFailure { error -> + NPLogger.w(TAG, "解析下载 metadata 失败: ${entry.name} - ${error.message}") + }.getOrNull() + } + + private fun buildRemoteTrackKey( + channelId: String?, + audioId: String?, + subAudioId: String? + ): String? { + val resolvedChannelId = channelId?.takeIf { it.isNotBlank() } ?: return null + val resolvedAudioId = audioId?.takeIf { it.isNotBlank() }.orEmpty() + val resolvedSubAudioId = subAudioId?.takeIf { it.isNotBlank() }.orEmpty() + if (resolvedAudioId.isBlank() && resolvedSubAudioId.isBlank()) { + return null + } + return "$resolvedChannelId|$resolvedAudioId|$resolvedSubAudioId" + } + private fun buildSnapshotCacheKey(context: Context): String { val configuredUri = normalizeDirectoryUri(customDirectoryUri) return if (configuredUri != null) { diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioDownloadManager.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioDownloadManager.kt index 2ec27d67..08d88152 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioDownloadManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioDownloadManager.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -136,7 +135,6 @@ object AudioDownloadManager { suspend fun downloadSong(context: Context, song: SongItem) { withContext(Dispatchers.IO) { val songKey = song.stableKey() - var sidecarJob: Job? = null try { // 检查文件是否已存在 if (LocalSongSupport.isLocalSong(song, context)) { @@ -189,8 +187,6 @@ object AudioDownloadManager { val tempFile = ManagedDownloadStorage.createWorkingFile(context, fileName) if (tempFile.exists()) tempFile.delete() - sidecarJob = launchSidecarDownload(context, song, baseName) - val reqBuilder = Request.Builder().url(url) if (isBili) { val cookieMap = AppContainer.biliCookieRepo.getCookiesOnce() @@ -245,6 +241,16 @@ object AudioDownloadManager { tempFile = tempFile, mimeType = mime ) + NPLogger.d( + TAG, + "音频落盘完成,开始写入 sidecar: song=${song.name}, audioFile=${storedAudio.name}, baseName=${storedAudio.nameWithoutExtension}" + ) + downloadSidecars( + context = context, + song = song, + baseName = storedAudio.nameWithoutExtension, + storedAudio = storedAudio + ) _progressFlow.value = null try { @@ -252,7 +258,6 @@ object AudioDownloadManager { } catch (_: Exception) { } } catch (e: Exception) { - sidecarJob?.cancel() if ( e is java.util.concurrent.CancellationException || _isCancelled.value || @@ -266,44 +271,41 @@ object AudioDownloadManager { NPLogger.e(TAG, "下载失败: ${song.name}, 错误: ${e.javaClass.simpleName} - ${e.message}", e) _progressFlow.value = null throw e // 重新抛出异常,让调用方知道下载失败 - } + } } } - private fun launchSidecarDownload( + private fun downloadSidecars( context: Context, song: SongItem, - baseName: String - ): Job { - return AppContainer.launchBackgroundIo { - runCatching { - downloadLyrics(context, song) - }.onFailure { error -> + baseName: String, + storedAudio: ManagedDownloadStorage.StoredEntry + ) { + runCatching { + downloadLyrics(context, song, baseName) + }.onFailure { error -> NPLogger.w(TAG, "歌词后台下载失败: ${song.name} - ${error.message}") - } - runCatching { - cacheCover(context, song, baseName) - }.onFailure { error -> + } + runCatching { + cacheCover(context, song, baseName, storedAudio) + }.onFailure { error -> NPLogger.w(TAG, "封面后台下载失败: ${song.name} - ${error.message}") } - } } private fun cacheCover( context: Context, song: SongItem, - baseName: String + baseName: String, + storedAudio: ManagedDownloadStorage.StoredEntry ) { val coverUrl = song.displayCoverUrl() if (coverUrl.isNullOrBlank()) { return } - val existingAudio = ManagedDownloadStorage.findAudio(context, song) - val existingCover = existingAudio?.let { - runBlocking(Dispatchers.IO) { - ManagedDownloadStorage.findCoverReference(context, it) - } + val existingCover = runBlocking(Dispatchers.IO) { + ManagedDownloadStorage.findCoverReference(context, storedAudio) } if (!existingCover.isNullOrBlank()) { return @@ -476,7 +478,7 @@ object AudioDownloadManager { } /** 下载歌词文件 */ - private fun downloadLyrics(context: Context, song: SongItem) { + private fun downloadLyrics(context: Context, song: SongItem, baseName: String) { try { var lyricText = song.matchedLyric?.takeIf { it.isNotBlank() } var translatedText: String? = null @@ -503,10 +505,10 @@ object AudioDownloadManager { } lyricText?.takeIf { it.isNotBlank() }?.let { lyric -> - writeManagedLyrics(context, song, lyric, translated = false) + writeManagedLyrics(context, song, baseName, lyric, translated = false) } translatedText?.takeIf { it.isNotBlank() }?.let { lyric -> - writeManagedLyrics(context, song, lyric, translated = true) + writeManagedLyrics(context, song, baseName, lyric, translated = true) } } catch (e: Exception) { NPLogger.w(TAG, "歌词下载失败: ${song.name} - ${e.message}") @@ -605,13 +607,14 @@ object AudioDownloadManager { private fun writeManagedLyrics( context: Context, song: SongItem, + baseName: String, content: String, translated: Boolean ) { ManagedDownloadStorage.writeLyrics( context = context, songId = song.id, - baseName = ManagedDownloadStorage.buildDisplayBaseName(song), + baseName = baseName, content = content, translated = translated ) diff --git a/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsRepository.kt b/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsRepository.kt index 3a5d1ed0..28ad86b9 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsRepository.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsRepository.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import moe.ouom.neriplayer.core.download.normalizeDownloadFileNameTemplate import moe.ouom.neriplayer.core.player.model.DEFAULT_PLAYBACK_PITCH import moe.ouom.neriplayer.core.player.model.DEFAULT_PLAYBACK_LOUDNESS_GAIN_MB import moe.ouom.neriplayer.core.player.model.DEFAULT_PLAYBACK_SPEED @@ -139,6 +140,11 @@ class SettingsRepository(private val context: Context) { val downloadDirectoryLabelFlow: Flow = context.dataStore.data.map { it[SettingsKeys.DOWNLOAD_DIRECTORY_LABEL] } + val downloadFileNameTemplateFlow: Flow = + context.dataStore.data.map { + normalizeDownloadFileNameTemplate(it[SettingsKeys.DOWNLOAD_FILE_NAME_TEMPLATE]) + } + val backgroundImageBlurFlow: Flow = context.dataStore.data.map { it[SettingsKeys.BACKGROUND_IMAGE_BLUR] ?: 0f } @@ -464,6 +470,17 @@ class SettingsRepository(private val context: Context) { } } + suspend fun setDownloadFileNameTemplate(template: String?) { + context.dataStore.edit { + val normalized = normalizeDownloadFileNameTemplate(template) + if (normalized == null) { + it.remove(SettingsKeys.DOWNLOAD_FILE_NAME_TEMPLATE) + } else { + it[SettingsKeys.DOWNLOAD_FILE_NAME_TEMPLATE] = normalized + } + } + } + suspend fun setShowLyricTranslation(enabled: Boolean) { context.dataStore.edit { it[SettingsKeys.SHOW_LYRIC_TRANSLATION] = enabled } } diff --git a/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsStore.kt b/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsStore.kt index 8036a9ad..ff308fab 100644 --- a/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsStore.kt +++ b/app/src/main/java/moe/ouom/neriplayer/data/settings/SettingsStore.kt @@ -70,6 +70,7 @@ object SettingsKeys { val BACKGROUND_IMAGE_URI = stringPreferencesKey("background_image_uri") val DOWNLOAD_DIRECTORY_URI = stringPreferencesKey("download_directory_uri") val DOWNLOAD_DIRECTORY_LABEL = stringPreferencesKey("download_directory_label") + val DOWNLOAD_FILE_NAME_TEMPLATE = stringPreferencesKey("download_file_name_template") val BACKGROUND_IMAGE_BLUR = floatPreferencesKey("background_image_blur") val BACKGROUND_IMAGE_ALPHA = floatPreferencesKey("background_image_alpha") val HAPTIC_FEEDBACK_ENABLED = booleanPreferencesKey("haptic_feedback_enabled") diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt index 4398c0fb..2a74f3c7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt @@ -447,6 +447,7 @@ fun NeriApp( val bypassProxy by repo.bypassProxyFlow.collectAsState(initial = true) val backgroundImageUri by repo.backgroundImageUriFlow.collectAsState(initial = null) val downloadDirectoryUri by repo.downloadDirectoryUriFlow.collectAsState(initial = null) + val downloadFileNameTemplate by repo.downloadFileNameTemplateFlow.collectAsState(initial = null) val backgroundImageBlur by repo.backgroundImageBlurFlow.collectAsState(initial = 0f) val backgroundImageAlpha by repo.backgroundImageAlphaFlow.collectAsState(initial = 0.3f) val hapticFeedbackEnabled by repo.hapticFeedbackEnabledFlow.collectAsState(initial = true) @@ -1292,6 +1293,7 @@ fun NeriApp( scope.launch { repo.setBackgroundImageUri(uri?.toString()) } }, downloadDirectoryUri = downloadDirectoryUri, + downloadFileNameTemplate = downloadFileNameTemplate, onDownloadDirectoryUriChange = { uri -> val label = ManagedDownloadStorage.describeConfiguredDirectory( context, @@ -1303,6 +1305,9 @@ fun NeriApp( ManagedDownloadStorage.updateCustomDirectoryLabel(label) } }, + onDownloadFileNameTemplateChange = { template -> + scope.launch { repo.setDownloadFileNameTemplate(template) } + }, backgroundImageBlur = backgroundImageBlur, onBackgroundImageBlurChange = {}, onBackgroundImageBlurChangeFinished = { blur -> diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/host/SettingsHostScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/host/SettingsHostScreen.kt index baeeea02..1d705e32 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/host/SettingsHostScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/host/SettingsHostScreen.kt @@ -99,7 +99,9 @@ fun SettingsHostScreen( backgroundImageUri: String?, onBackgroundImageChange: (Uri?) -> Unit, downloadDirectoryUri: String?, + downloadFileNameTemplate: String?, onDownloadDirectoryUriChange: (String?) -> Unit, + onDownloadFileNameTemplateChange: (String?) -> Unit, backgroundImageBlur: Float, onBackgroundImageBlurChange: (Float) -> Unit, onBackgroundImageBlurChangeFinished: (Float) -> Unit, @@ -247,7 +249,9 @@ fun SettingsHostScreen( backgroundImageUri = backgroundImageUri, onBackgroundImageChange = onBackgroundImageChange, downloadDirectoryUri = downloadDirectoryUri, + downloadFileNameTemplate = downloadFileNameTemplate, onDownloadDirectoryUriChange = onDownloadDirectoryUriChange, + onDownloadFileNameTemplateChange = onDownloadFileNameTemplateChange, backgroundImageBlur = backgroundImageBlur, onBackgroundImageBlurChange = onBackgroundImageBlurChange, onBackgroundImageBlurChangeFinished = onBackgroundImageBlurChangeFinished, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt index 155ef356..38647eee 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/SettingsScreen.kt @@ -216,7 +216,9 @@ fun SettingsScreen( backgroundImageUri: String?, onBackgroundImageChange: (Uri?) -> Unit, downloadDirectoryUri: String?, + downloadFileNameTemplate: String?, onDownloadDirectoryUriChange: (String?) -> Unit, + onDownloadFileNameTemplateChange: (String?) -> Unit, backgroundImageBlur: Float, onBackgroundImageBlurChange: (Float) -> Unit, onBackgroundImageBlurChangeFinished: (Float) -> Unit, @@ -1558,6 +1560,8 @@ fun SettingsScreen( isCustomDownloadDirectory = !downloadDirectoryUri.isNullOrBlank(), onPickDownloadDirectory = { downloadDirectoryLauncher.launch(null) }, onResetDownloadDirectory = resetDownloadDirectory, + downloadFileNameTemplate = downloadFileNameTemplate, + onDownloadFileNameTemplateChange = onDownloadFileNameTemplateChange, maxCacheSizeBytes = maxCacheSizeBytes, onMaxCacheSizeBytesChange = onMaxCacheSizeBytesChange, showStorageDetails = showStorageDetails, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt index c9cb69ab..31edf04c 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.Text @@ -61,6 +62,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,7 +77,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import moe.ouom.neriplayer.R +import moe.ouom.neriplayer.core.download.DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE import moe.ouom.neriplayer.core.download.ManagedDownloadStorage +import moe.ouom.neriplayer.core.download.normalizeDownloadFileNameTemplate +import moe.ouom.neriplayer.core.download.renderManagedDownloadBaseName import moe.ouom.neriplayer.util.HapticTextButton import moe.ouom.neriplayer.util.formatFileSize @@ -88,6 +93,8 @@ internal fun SettingsStorageCacheSection( isCustomDownloadDirectory: Boolean, onPickDownloadDirectory: () -> Unit, onResetDownloadDirectory: () -> Unit, + downloadFileNameTemplate: String?, + onDownloadFileNameTemplateChange: (String?) -> Unit, maxCacheSizeBytes: Long, onMaxCacheSizeBytesChange: (Long) -> Unit, showStorageDetails: Boolean, @@ -105,6 +112,29 @@ internal fun SettingsStorageCacheSection( val context = LocalContext.current val scope = rememberCoroutineScope() var isStorageDetailsLoading by remember { mutableStateOf(false) } + var showDownloadFileNameDialog by remember { mutableStateOf(false) } + var pendingDownloadFileNameTemplate by rememberSaveable { + mutableStateOf(downloadFileNameTemplate ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE) + } + + androidx.compose.runtime.LaunchedEffect(downloadFileNameTemplate) { + val savedValue = downloadFileNameTemplate ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + if (pendingDownloadFileNameTemplate != savedValue) { + pendingDownloadFileNameTemplate = savedValue + } + } + + val effectiveTemplate = normalizeDownloadFileNameTemplate( + pendingDownloadFileNameTemplate + ) ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + val currentSavedTemplate = downloadFileNameTemplate ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + val samplePreview = renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + template = effectiveTemplate + ) + val canApplyDownloadFileNameTemplate = effectiveTemplate != currentSavedTemplate ExpandableHeader( icon = Icons.Outlined.SdStorage, @@ -180,6 +210,55 @@ internal fun SettingsStorageCacheSection( ) } + val effectiveTemplate = normalizeDownloadFileNameTemplate( + pendingDownloadFileNameTemplate + ) ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + val currentSavedTemplate = downloadFileNameTemplate ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + val samplePreview = renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + template = effectiveTemplate + ) + + ListItem( + leadingContent = { + Icon( + Icons.Outlined.Download, + contentDescription = stringResource(R.string.settings_download_file_name_format), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + headlineContent = { Text(stringResource(R.string.settings_download_file_name_format)) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.settings_download_file_name_format_desc)) + Text( + text = effectiveTemplate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource( + R.string.settings_download_file_name_format_preview, + samplePreview + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + }, + trailingContent = { + Text( + text = stringResource(R.string.action_details), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + }, + modifier = Modifier.settingsItemClickable { showDownloadFileNameDialog = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + ListItem( headlineContent = { Text(stringResource(R.string.settings_cache_limit)) }, supportingContent = { @@ -396,6 +475,84 @@ internal fun SettingsStorageCacheSection( } ) } + + if (showDownloadFileNameDialog) { + AlertDialog( + onDismissRequest = { showDownloadFileNameDialog = false }, + title = { Text(stringResource(R.string.settings_download_file_name_format)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.settings_download_file_name_format_desc)) + OutlinedTextField( + value = pendingDownloadFileNameTemplate, + onValueChange = { pendingDownloadFileNameTemplate = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text(DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE) + } + ) + Text( + text = stringResource(R.string.settings_download_file_name_format_supported), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Text( + text = stringResource( + R.string.settings_download_file_name_format_preview, + normalizeDownloadFileNameTemplate(pendingDownloadFileNameTemplate)?.let { template -> + renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + template = template + ) + } ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE.let { template -> + renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + template = template + ) + } + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + HapticTextButton( + onClick = { + onDownloadFileNameTemplateChange( + normalizeDownloadFileNameTemplate(pendingDownloadFileNameTemplate) + ) + showDownloadFileNameDialog = false + }, + enabled = canApplyDownloadFileNameTemplate + ) { + Text(stringResource(R.string.action_apply)) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HapticTextButton( + onClick = { + pendingDownloadFileNameTemplate = DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + onDownloadFileNameTemplateChange(null) + showDownloadFileNameDialog = false + }, + enabled = currentSavedTemplate != DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE + ) { + Text(stringResource(R.string.action_reset)) + } + HapticTextButton(onClick = { showDownloadFileNameDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + } + ) + } } @Composable diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5eb19f0d..28a099d5 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1040,6 +1040,10 @@ Choose Folder Restore Default Folder Switch back to the app private download folder + Saved File Name Format + Customize how downloaded songs are named when saved locally + Supported placeholders: %%artist%% , %%title%% , %%album%% , %%source%% , %%id%% , %%audioId%% , %%subAudioId%% + Preview: %1$s App private folder Custom Directory Internal Storage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 662339c0..bf4d59ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1040,6 +1040,10 @@ 选择目录 恢复默认目录 改回应用自己的下载位置 + 保存文件名格式 + 自定义下载歌曲保存到本地时的文件名格式 + 支持占位符:%%artist%% 、%%title%% 、%%album%% 、%%source%% 、%%id%% 、%%audioId%% 、%%subAudioId%% + 预览:%1$s 应用私有目录 自定义目录 内部存储 diff --git a/app/src/test/java/moe/ouom/neriplayer/core/di/AppContainerBootstrapTest.kt b/app/src/test/java/moe/ouom/neriplayer/core/di/AppContainerBootstrapTest.kt index aeae6325..996fbd35 100644 --- a/app/src/test/java/moe/ouom/neriplayer/core/di/AppContainerBootstrapTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/core/di/AppContainerBootstrapTest.kt @@ -29,22 +29,26 @@ class AppContainerBootstrapTest { fun `resolveInitialManagedDownloadSettings prefers persisted values`() { val resolved = resolveInitialManagedDownloadSettings( loadDirectoryUri = { "content://downloads/tree/neri" }, - loadDirectoryLabel = { "SD Card" } + loadDirectoryLabel = { "SD Card" }, + loadFileNameTemplate = { "%title%" } ) assertEquals("content://downloads/tree/neri", resolved.directoryUri) assertEquals("SD Card", resolved.directoryLabel) + assertEquals("%title%", resolved.fileNameTemplate) } @Test fun `resolveInitialManagedDownloadSettings normalizes blanks to null`() { val resolved = resolveInitialManagedDownloadSettings( loadDirectoryUri = { " " }, - loadDirectoryLabel = { "" } + loadDirectoryLabel = { "" }, + loadFileNameTemplate = { " " } ) assertNull(resolved.directoryUri) assertNull(resolved.directoryLabel) + assertNull(resolved.fileNameTemplate) } @Test @@ -52,12 +56,15 @@ class AppContainerBootstrapTest { val resolved = resolveInitialManagedDownloadSettings( currentDirectoryUri = "content://downloads/tree/current", currentDirectoryLabel = "Current", + currentFileNameTemplate = "%artist%", loadDirectoryUri = { error("boom-uri") }, - loadDirectoryLabel = { error("boom-label") } + loadDirectoryLabel = { error("boom-label") }, + loadFileNameTemplate = { error("boom-template") } ) assertEquals("content://downloads/tree/current", resolved.directoryUri) assertEquals("Current", resolved.directoryLabel) + assertEquals("%artist%", resolved.fileNameTemplate) } @Test @@ -65,12 +72,15 @@ class AppContainerBootstrapTest { val resolved = resolveInitialManagedDownloadSettings( currentDirectoryUri = "content://downloads/tree/current", currentDirectoryLabel = "Current", + currentFileNameTemplate = "%artist%", loadDirectoryUri = { error("boom-uri") }, - loadDirectoryLabel = { "USB Music" } + loadDirectoryLabel = { "USB Music" }, + loadFileNameTemplate = { "%album% - %title%" } ) assertEquals("content://downloads/tree/current", resolved.directoryUri) assertEquals("USB Music", resolved.directoryLabel) + assertEquals("%album% - %title%", resolved.fileNameTemplate) } @Test diff --git a/app/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadNamingTest.kt b/app/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadNamingTest.kt new file mode 100644 index 00000000..83d1951c --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadNamingTest.kt @@ -0,0 +1,90 @@ +package moe.ouom.neriplayer.core.download + +import moe.ouom.neriplayer.ui.viewmodel.playlist.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManagedDownloadNamingTest { + + @Test + fun `renderManagedDownloadBaseName uses default template`() { + val result = renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + source = "netease" + ) + + assertEquals("netease - 周杰伦 - 晴天", result) + } + + @Test + fun `renderManagedDownloadBaseName applies custom template`() { + val result = renderManagedDownloadBaseName( + title = "晴天", + artist = "周杰伦", + album = "叶惠美", + template = "%album% - %title%" + ) + + assertEquals("叶惠美 - 晴天", result) + } + + @Test + fun `candidateManagedDownloadBaseNames keeps legacy artist title name after template changes`() { + val song = SongItem( + id = 1L, + name = "晴天", + artist = "周杰伦", + album = "叶惠美", + albumId = 2L, + durationMs = 1000L, + coverUrl = null + ) + + val candidates = candidateManagedDownloadBaseNames(song) + + assertTrue(candidates.contains("周杰伦 - 晴天")) + assertTrue(candidates.contains("netease - 周杰伦 - 晴天")) + } + + @Test + fun `candidateManagedDownloadBaseNames includes active custom template result`() { + val song = SongItem( + id = 1L, + name = "晴天", + artist = "周杰伦", + album = "叶惠美", + albumId = 2L, + durationMs = 1000L, + coverUrl = null + ) + + val candidates = candidateManagedDownloadBaseNames(song, activeTemplate = "%album% - %title%") + + assertTrue(candidates.contains("叶惠美 - 晴天")) + } + @Test + fun `candidateManagedDownloadBaseNames keeps suffixed and raw audio base names`() { + val candidates = candidateManagedDownloadBaseNames("Artist - Title (1)") + + assertEquals(listOf("Artist - Title (1)", "Artist - Title"), candidates) + } + + @Test + fun `renderManagedDownloadBaseName supports source and identity placeholders`() { + val result = renderManagedDownloadBaseName( + title = "Song", + artist = "Artist", + album = "Album", + source = "netease", + songId = "123", + audioId = "456", + subAudioId = "789", + template = "%source% - %artist% - %title% - %id% - %audioId% - %subAudioId%" + ) + + assertEquals("netease - Artist - Song - 123 - 456 - 789", result) + } +} From c6b591cc04c6f83b2d0ebf7497fd2e4169d3bf64 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 28 Mar 2026 10:50:00 +0800 Subject: [PATCH 06/11] feat(library): support multi-select deletion for favorite playlists --- .../neriplayer/ui/screen/tab/LibraryScreen.kt | 163 +++++++++++++++--- 1 file changed, 136 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/LibraryScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/LibraryScreen.kt index 74cd0592..68d7da13 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/LibraryScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/LibraryScreen.kt @@ -1382,15 +1382,37 @@ private fun FavoritePlaylistList( val miniPlayerHeight = LocalMiniPlayerHeight.current val scope = rememberCoroutineScope() var sortMode by rememberSaveable { mutableStateOf(false) } + var selectedKeys by remember { mutableStateOf>(emptySet()) } + var showDeleteSelectedConfirm by rememberSaveable { mutableStateOf(false) } val reorderableFavorites = remember { mutableStateListOf() } - BackHandler(enabled = sortMode) { sortMode = false } + fun favoriteKey(favorite: moe.ouom.neriplayer.data.playlist.favorite.FavoritePlaylist): String { + return "${favorite.source}:${favorite.id}" + } + + fun exitEditMode() { + sortMode = false + selectedKeys = emptySet() + showDeleteSelectedConfirm = false + } + + fun toggleSelection(key: String) { + selectedKeys = if (selectedKeys.contains(key)) { + selectedKeys - key + } else { + selectedKeys + key + } + } + + BackHandler(enabled = sortMode) { exitEditMode() } LaunchedEffect(favorites) { reorderableFavorites.clear() reorderableFavorites.addAll(favorites) + val validKeys = favorites.map(::favoriteKey).toSet() + selectedKeys = selectedKeys.intersect(validKeys) if (sortMode && favorites.isEmpty()) { - sortMode = false + exitEditMode() } } @@ -1400,8 +1422,8 @@ private fun FavoritePlaylistList( if (!sortMode) return@rememberReorderableLazyListState val fromKey = from.key as? String ?: return@rememberReorderableLazyListState val toKey = to.key as? String ?: return@rememberReorderableLazyListState - val fromIndex = reorderableFavorites.indexOfFirst { "${it.source}:${it.id}" == fromKey } - val toIndex = reorderableFavorites.indexOfFirst { "${it.source}:${it.id}" == toKey } + val fromIndex = reorderableFavorites.indexOfFirst { favoriteKey(it) == fromKey } + val toIndex = reorderableFavorites.indexOfFirst { favoriteKey(it) == toKey } if (fromIndex != -1 && toIndex != -1 && fromIndex != toIndex) { reorderableFavorites.add(toIndex, reorderableFavorites.removeAt(fromIndex)) } @@ -1429,6 +1451,8 @@ private fun FavoritePlaylistList( val cardShape = RoundedCornerShape(12.dp) if (sortMode) { item(key = "favorite_sort_mode_header") { + val allSelected = + selectedKeys.size == reorderableFavorites.size && reorderableFavorites.isNotEmpty() Card( shape = cardShape, colors = CardDefaults.cardColors( @@ -1440,21 +1464,53 @@ private fun FavoritePlaylistList( .clip(cardShape) ) { ListItem( - headlineContent = { Text(stringResource(R.string.library_favorite_sort_mode_title)) }, - supportingContent = { + headlineContent = { Text( - stringResource(R.string.library_favorite_sort_mode_desc), - color = MaterialTheme.colorScheme.onSurfaceVariant + pluralStringResource( + R.plurals.common_selected_count, + selectedKeys.size, + selectedKeys.size + ) ) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { - HapticIconButton(onClick = { sortMode = false }) { + HapticIconButton(onClick = { exitEditMode() }) { Icon( imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.action_cancel) + contentDescription = stringResource(R.string.action_exit_multi_select) ) } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + HapticTextButton( + onClick = { + selectedKeys = if (allSelected) { + emptySet() + } else { + reorderableFavorites.map(::favoriteKey).toSet() + } + } + ) { + Text( + if (allSelected) { + stringResource(R.string.action_deselect_all) + } else { + stringResource(R.string.action_select_all) + } + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + HapticTextButton( + enabled = selectedKeys.isNotEmpty(), + onClick = { showDeleteSelectedConfirm = true } + ) { + Text(stringResource(R.string.common_delete_selected)) + } + } } ) } @@ -1497,13 +1553,17 @@ private fun FavoritePlaylistList( } else { items( items = reorderableFavorites, - key = { "${it.source}:${it.id}" } + key = { favoriteKey(it) } ) { favorite -> - ReorderableItem(state = reorderState, key = "${favorite.source}:${favorite.id}") { + val itemKey = favoriteKey(favorite) + val isSelected = sortMode && selectedKeys.contains(itemKey) + ReorderableItem(state = reorderState, key = itemKey) { Card( shape = cardShape, colors = CardDefaults.cardColors( - containerColor = if (sortMode) { + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.28f) + } else if (sortMode) { MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.12f) } else { Color.Transparent @@ -1516,7 +1576,10 @@ private fun FavoritePlaylistList( .clip(cardShape) .combinedClickable( onClick = { - if (sortMode) return@combinedClickable + if (sortMode) { + toggleSelection(itemKey) + return@combinedClickable + } when (favorite.source) { "netease" -> { onNeteasePlaylistClick( @@ -1569,9 +1632,8 @@ private fun FavoritePlaylistList( } }, onLongClick = { - if (!sortMode) { - sortMode = true - } + if (!sortMode) sortMode = true + toggleSelection(itemKey) } ) ) { @@ -1582,7 +1644,7 @@ private fun FavoritePlaylistList( stringResource( R.string.library_favorite_source_format, favorite.trackCount, - favorite.source + favoriteSourceLabel(favorite.source) ), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -1611,16 +1673,22 @@ private fun FavoritePlaylistList( }, trailingContent = { if (sortMode) { - Box( - modifier = Modifier - .detectReorder(reorderState) - .padding(8.dp) - ) { - Icon( - imageVector = Icons.Filled.DragHandle, - contentDescription = stringResource(R.string.common_drag_handle), - modifier = Modifier.size(24.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isSelected, + onCheckedChange = { toggleSelection(itemKey) } ) + Box( + modifier = Modifier + .detectReorder(reorderState) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Filled.DragHandle, + contentDescription = stringResource(R.string.common_drag_handle), + modifier = Modifier.size(24.dp) + ) + } } } } @@ -1630,6 +1698,47 @@ private fun FavoritePlaylistList( } } } + + if (showDeleteSelectedConfirm) { + AlertDialog( + onDismissRequest = { showDeleteSelectedConfirm = false }, + title = { Text(stringResource(R.string.dialog_confirm_delete)) }, + text = { + Text( + pluralStringResource( + R.plurals.library_delete_selected_confirm, + selectedKeys.size, + selectedKeys.size + ) + ) + }, + confirmButton = { + HapticTextButton( + onClick = { + val targets = reorderableFavorites.filter { favoriteKey(it) in selectedKeys } + scope.launch { + targets.forEach { favoriteRepo.removeFavorite(it.id, it.source) } + exitEditMode() + } + } + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + HapticTextButton(onClick = { showDeleteSelectedConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } +} + +private fun favoriteSourceLabel(source: String): String { + return when (source) { + "youtubeMusic" -> "YouTube" + else -> source + } } @Composable From 7932763fa231fd4aaa68a591e91b747b58be2903 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 28 Mar 2026 11:18:55 +0800 Subject: [PATCH 07/11] feat(listen-together): polish user room status layout and metadata --- .../screen/debug/ListenTogetherDebugPanel.kt | 565 +++++++++++++----- .../component/SettingsStorageCacheSection.kt | 28 +- app/src/main/res/values/strings.xml | 56 +- 3 files changed, 429 insertions(+), 220 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt index 6f997384..39392f4b 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/debug/ListenTogetherDebugPanel.kt @@ -117,14 +117,15 @@ fun ListenTogetherDebugScreen() { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - ListenTogetherRoomPanel(showBaseUrlEditor = true) + ListenTogetherRoomPanel(showBaseUrlEditor = true, showAdvancedDebug = true) } } @Composable fun ListenTogetherRoomPanel( modifier: Modifier = Modifier, - showBaseUrlEditor: Boolean = false + showBaseUrlEditor: Boolean = false, + showAdvancedDebug: Boolean = false ) { val context = LocalContext.current val activity = remember(context) { context.findComponentActivity() } @@ -175,8 +176,8 @@ fun ListenTogetherRoomPanel( LaunchedEffect(savedBaseUrl) { if (baseUrl.isBlank()) baseUrl = resolveListenTogetherBaseUrl(savedBaseUrl) } - LaunchedEffect(savedUserUuid) { - if (userUuid.isBlank()) { + LaunchedEffect(savedUserUuid, isInRoom) { + if (!isInRoom) { userUuid = if (savedUserUuid.isBlank()) preferences.getOrCreateUserUuid() else savedUserUuid } } @@ -207,176 +208,298 @@ fun ListenTogetherRoomPanel( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) ) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - DebugHeader( - connectionState = sessionState.connectionState, - role = role, - roomStatus = roomState?.roomStatus, - roomVersion = roomState?.version, - roomId = sessionState.roomId - ) - - if (showBaseUrlEditor) { - OutlinedTextField( - value = baseUrl, - onValueChange = { baseUrl = it }, + if (showAdvancedDebug) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + DebugHeader( + connectionState = sessionState.connectionState, + role = role, + roomStatus = roomState?.roomStatus, + roomVersion = roomState?.version, + roomId = sessionState.roomId + ) + if (showBaseUrlEditor) { + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.listen_together_worker_base_url)) }, + singleLine = true + ) + } + Row( modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.listen_together_worker_base_url)) }, - singleLine = true + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it.trim().take(24) }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.listen_together_nickname)) }, + singleLine = true + ) + OutlinedTextField( + value = roomIdInput, + onValueChange = { roomIdInput = normalizeListenTogetherRoomId(it).take(6) }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.listen_together_room_id)) }, + singleLine = true, + readOnly = isInRoom + ) + } + runningActionResId?.let { resId -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(resId), style = MaterialTheme.typography.bodySmall) + } + } + validateListenTogetherNickname(nickname)?.let { ErrorText(it) } + if (!isInRoom) { + validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { ErrorText(it) } + } + QuickActionSection( + activity = activity, + sessionState = sessionState, + effectiveBaseUrl = effectiveBaseUrl, + clipboard = clipboard, + onRunningActionChange = { runningActionResId = it } + ) + if (!isInRoom) { + RoomActions( + runningActionResId = runningActionResId, + currentQueue = currentQueue, + currentSong = currentSong, + isPlaying = isPlaying, + positionMs = positionMs, + activity = activity, + userUuid = userUuid, + nickname = nickname, + roomIdInput = roomIdInput, + effectiveBaseUrl = effectiveBaseUrl, + roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + sessionState = sessionState, + preferences = preferences, + sessionManager = sessionManager, + onRunningActionChange = { runningActionResId = it } + ) + } else { + ConnectedActions( + runningActionResId = runningActionResId, + effectiveBaseUrl = effectiveBaseUrl, + nickname = nickname, + roomIdInput = roomIdInput, + sessionState = sessionState, + sessionManager = sessionManager, + preferences = preferences, + activity = activity, + onRunningActionChange = { runningActionResId = it } + ) + } + if (isController) { + TextButton( + onClick = { + val roomId = sessionState.roomId ?: return@TextButton + val inviteText = buildString { + append(context.getString(R.string.listen_together_invite_share_text, sessionState.nickname ?: context.getString(R.string.listen_together_title), roomId)) + inviteUri?.let { + append("\n") + append(it) + } + } + clipboard.setText(AnnotatedString(inviteText)) + Toast.makeText(context, context.getString(R.string.listen_together_invite_copied), Toast.LENGTH_SHORT).show() + }, + enabled = !sessionState.roomId.isNullOrBlank() + ) { + Text(stringResource(R.string.listen_together_copy_invite)) + } + } + if (isController || !isInRoom) { + HorizontalDivider() + SettingsSection( + settings = if (isInRoom) roomSettings else ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + enabled = runningActionResId == null && (!isInRoom || isController), + onSettingsChange = { updated -> + allowMemberControl = updated.allowMemberControl + autoPauseOnMemberChange = updated.autoPauseOnMemberChange + shareAudioLinks = updated.shareAudioLinks + activity?.lifecycleScope?.launch { + runCatching { + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, updated) + if (isInRoom && isController) { + val result = sessionManager.updateRoomSettings(updated) + check(result.ok) { + result.error ?: context.getString(R.string.listen_together_debug_ws_unavailable) + } + } + }.onFailure { + Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() + } + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + } + ) + } + HorizontalDivider() + StatusSection( + sessionState = sessionState, + roomState = roomState, + role = role, + fallbackTrackName = currentSong?.name, + isPlaying = isPlaying, + effectiveBaseUrl = effectiveBaseUrl, + tokenPreview = tokenPreview, + expanded = showSessionDetails, + onToggleExpanded = { showSessionDetails = !showSessionDetails } + ) + TrackDebugSection( + track = roomState?.track, + fallbackTrackName = currentSong?.name, + expanded = showTrackPayload, + onToggleExpanded = { showTrackPayload = !showTrackPayload } + ) + MemberSection( + members = roomState?.members?.sortedBy { it.joinedAt }.orEmpty(), + expanded = showMemberDetails, + onToggleExpanded = { showMemberDetails = !showMemberDetails } ) } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + if (showBaseUrlEditor) { + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + label = { Text(stringResource(R.string.listen_together_worker_base_url)) }, + singleLine = true + ) + } OutlinedTextField( value = nickname, onValueChange = { nickname = it.trim().take(24) }, - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), label = { Text(stringResource(R.string.listen_together_nickname)) }, singleLine = true ) OutlinedTextField( value = roomIdInput, onValueChange = { roomIdInput = normalizeListenTogetherRoomId(it).take(6) }, - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), label = { Text(stringResource(R.string.listen_together_room_id)) }, singleLine = true, readOnly = isInRoom ) - } - - runningActionResId?.let { resId -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - Text(stringResource(resId), style = MaterialTheme.typography.bodySmall) + runningActionResId?.let { resId -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(resId), style = MaterialTheme.typography.bodySmall) + } } - } - - validateListenTogetherNickname(nickname)?.let { ErrorText(it) } - if (!isInRoom) { - validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { ErrorText(it) } - } - - QuickActionSection( - activity = activity, - sessionState = sessionState, - effectiveBaseUrl = effectiveBaseUrl, - clipboard = clipboard, - onRunningActionChange = { runningActionResId = it } - ) - - if (!isInRoom) { - RoomActions( - runningActionResId = runningActionResId, - currentQueue = currentQueue, - currentSong = currentSong, - isPlaying = isPlaying, - positionMs = positionMs, - activity = activity, - userUuid = userUuid, - nickname = nickname, - roomIdInput = roomIdInput, - effectiveBaseUrl = effectiveBaseUrl, - roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), - sessionState = sessionState, - preferences = preferences, - sessionManager = sessionManager, - onRunningActionChange = { runningActionResId = it } - ) - } else { - ConnectedActions( - runningActionResId = runningActionResId, - effectiveBaseUrl = effectiveBaseUrl, - nickname = nickname, - roomIdInput = roomIdInput, - sessionState = sessionState, - sessionManager = sessionManager, - preferences = preferences, - activity = activity, - onRunningActionChange = { runningActionResId = it } - ) - } - - if (isController) { - TextButton( - onClick = { - val roomId = sessionState.roomId ?: return@TextButton - val inviteText = buildString { - append(context.getString(R.string.listen_together_invite_share_text, sessionState.nickname ?: context.getString(R.string.listen_together_title), roomId)) - inviteUri?.let { - append("\n") - append(it) - } - } - clipboard.setText(AnnotatedString(inviteText)) - Toast.makeText(context, context.getString(R.string.listen_together_invite_copied), Toast.LENGTH_SHORT).show() - }, - enabled = !sessionState.roomId.isNullOrBlank() - ) { - Text(stringResource(R.string.listen_together_copy_invite)) + validateListenTogetherNickname(nickname)?.let { SimpleErrorText(it) } + if (!isInRoom) { + validateListenTogetherRoomId(roomIdInput)?.takeIf { roomIdInput.isNotBlank() }?.let { SimpleErrorText(it) } } - } - - if (isController || !isInRoom) { - HorizontalDivider() - SettingsSection( - settings = if (isInRoom) roomSettings else ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), - enabled = runningActionResId == null && (!isInRoom || isController), - onSettingsChange = { updated -> - allowMemberControl = updated.allowMemberControl - autoPauseOnMemberChange = updated.autoPauseOnMemberChange - shareAudioLinks = updated.shareAudioLinks - activity?.lifecycleScope?.launch { - runCatching { - persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, updated) - if (isInRoom && isController) { - val result = sessionManager.updateRoomSettings(updated) - check(result.ok) { - result.error ?: context.getString(R.string.listen_together_debug_ws_unavailable) - } + if (!isInRoom) { + RoomActions( + modifier = Modifier.padding(horizontal = 20.dp), + runningActionResId = runningActionResId, + currentQueue = currentQueue, + currentSong = currentSong, + isPlaying = isPlaying, + positionMs = positionMs, + activity = activity, + userUuid = userUuid, + nickname = nickname, + roomIdInput = roomIdInput, + effectiveBaseUrl = effectiveBaseUrl, + roomSettings = ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + sessionState = sessionState, + preferences = preferences, + sessionManager = sessionManager, + onRunningActionChange = { runningActionResId = it } + ) + } + if (isInRoom) { + ConnectedActions( + modifier = Modifier.padding(horizontal = 20.dp), + runningActionResId = runningActionResId, + effectiveBaseUrl = effectiveBaseUrl, + nickname = nickname, + roomIdInput = roomIdInput, + sessionState = sessionState, + sessionManager = sessionManager, + preferences = preferences, + activity = activity, + onRunningActionChange = { runningActionResId = it } + ) + } + if (isController) { + TextButton( + onClick = { + val roomId = sessionState.roomId ?: return@TextButton + val inviteText = buildString { + append(context.getString(R.string.listen_together_invite_share_text, sessionState.nickname ?: context.getString(R.string.listen_together_title), roomId)) + inviteUri?.let { + append("\n") + append(it) } - }.onFailure { - Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } - } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() - } - ) + clipboard.setText(AnnotatedString(inviteText)) + Toast.makeText(context, context.getString(R.string.listen_together_invite_copied), Toast.LENGTH_SHORT).show() + }, + enabled = !sessionState.roomId.isNullOrBlank(), + modifier = Modifier.padding(horizontal = 12.dp) + ) { Text(stringResource(R.string.listen_together_copy_invite)) } + } + if (isController || !isInRoom) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + SettingsSection( + modifier = Modifier.padding(horizontal = 20.dp), + settings = if (isInRoom) roomSettings else ListenTogetherRoomSettings(allowMemberControl, autoPauseOnMemberChange, shareAudioLinks), + enabled = runningActionResId == null && (!isInRoom || isController), + onSettingsChange = { updated -> + allowMemberControl = updated.allowMemberControl + autoPauseOnMemberChange = updated.autoPauseOnMemberChange + shareAudioLinks = updated.shareAudioLinks + activity?.lifecycleScope?.launch { + runCatching { + persistSettings(preferences, effectiveBaseUrl, userUuid, nickname, updated) + if (isInRoom && isController) { + val result = sessionManager.updateRoomSettings(updated) + check(result.ok) { result.error ?: "websocket unavailable" } + } + }.onFailure { Toast.makeText(context, it.message ?: it.javaClass.simpleName, Toast.LENGTH_SHORT).show() } + } ?: Toast.makeText(context, context.getString(R.string.listen_together_action_unavailable), Toast.LENGTH_SHORT).show() + } + ) + } + SimpleStatusSection(sessionState, roomState, role, currentSong?.name, isPlaying) + SimpleMemberSection(roomState?.members?.sortedBy { it.joinedAt }.orEmpty()) } - - HorizontalDivider() - StatusSection( - sessionState = sessionState, - roomState = roomState, - role = role, - fallbackTrackName = currentSong?.name, - isPlaying = isPlaying, - effectiveBaseUrl = effectiveBaseUrl, - tokenPreview = tokenPreview, - expanded = showSessionDetails, - onToggleExpanded = { showSessionDetails = !showSessionDetails } - ) - TrackDebugSection( - track = roomState?.track, - fallbackTrackName = currentSong?.name, - expanded = showTrackPayload, - onToggleExpanded = { showTrackPayload = !showTrackPayload } - ) - MemberSection( - members = roomState?.members?.sortedBy { it.joinedAt }.orEmpty(), - expanded = showMemberDetails, - onToggleExpanded = { showMemberDetails = !showMemberDetails } - ) } } } @@ -479,6 +602,7 @@ private fun QuickActionSection( @Composable private fun RoomActions( + modifier: Modifier = Modifier, runningActionResId: Int?, currentQueue: List, currentSong: SongItem?, @@ -501,7 +625,7 @@ private fun RoomActions( val roomIdError = validateListenTogetherRoomId(roomIdInput) Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Button( @@ -571,6 +695,7 @@ private fun RoomActions( @Composable private fun ConnectedActions( + modifier: Modifier = Modifier, runningActionResId: Int?, effectiveBaseUrl: String, nickname: String, @@ -583,7 +708,7 @@ private fun ConnectedActions( ) { val context = LocalContext.current Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Button( @@ -626,12 +751,13 @@ private fun Context.findComponentActivity(): ComponentActivity? = when (this) { @Composable private fun SettingsSection( + modifier: Modifier = Modifier, settings: ListenTogetherRoomSettings, enabled: Boolean, onSettingsChange: (ListenTogetherRoomSettings) -> Unit ) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text(stringResource(R.string.listen_together_settings_title), style = MaterialTheme.typography.titleSmall) @@ -690,7 +816,7 @@ private fun StatusSection( onToggleExpanded: () -> Unit ) { val context = LocalContext.current - val playbackState = if ((roomState?.playback?.state ?: if (isPlaying) "playing" else "paused") == "playing") { + val playbackState = if (resolveDisplayedPlaybackState(roomState, role, isPlaying) == "playing") { stringResource(R.string.listen_together_playback_playing) } else { stringResource(R.string.listen_together_playback_paused) @@ -706,7 +832,6 @@ private fun StatusSection( DebugField(stringResource(R.string.listen_together_playback), playbackState) ) val detailFields = buildList { - addAll(summaryFields) add(DebugField(stringResource(R.string.listen_together_track), roomState?.track?.name ?: fallbackTrackName ?: "-")) add(DebugField(stringResource(R.string.listen_together_debug_base_url), effectiveBaseUrl)) add(DebugField(stringResource(R.string.listen_together_debug_ws_url), sessionState.wsUrl ?: "-")) @@ -761,15 +886,11 @@ private fun TrackDebugSection( DebugField(stringResource(R.string.listen_together_debug_stable_key), track?.stableKey ?: "-") ) val detailFields = listOf( - DebugField(stringResource(R.string.listen_together_debug_track_name), track?.name ?: fallbackTrackName ?: "-"), - DebugField(stringResource(R.string.listen_together_debug_stable_key), track?.stableKey ?: "-"), - DebugField(stringResource(R.string.listen_together_debug_channel), track?.channelId ?: "-"), DebugField(stringResource(R.string.listen_together_debug_audio_id), track?.audioId ?: "-"), DebugField(stringResource(R.string.listen_together_debug_sub_audio_id), track?.subAudioId ?: "-"), DebugField(stringResource(R.string.listen_together_debug_playlist_context), track?.playlistContextId ?: "-"), DebugField(stringResource(R.string.listen_together_debug_media_uri), track?.mediaUri ?: "-"), DebugField(stringResource(R.string.listen_together_debug_stream_url), track?.streamUrl ?: "-"), - DebugField(stringResource(R.string.listen_together_debug_duration), track?.durationMs?.let(::formatDurationDebug) ?: "-"), DebugField(stringResource(R.string.listen_together_debug_cover), track?.coverUrl ?: "-") ) @@ -946,9 +1067,104 @@ private fun ErrorText(message: String) { ) } +@Composable +private fun SimpleErrorText(message: String) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp) + ) +} + +@Composable +private fun SimpleStatusSection( + sessionState: moe.ouom.neriplayer.listentogether.ListenTogetherSessionState, + roomState: ListenTogetherRoomState?, + role: String?, + fallbackTrackName: String?, + isPlaying: Boolean +) { + val context = LocalContext.current + val playbackState = if (resolveDisplayedPlaybackState(roomState, role, isPlaying) == "playing") { + stringResource(R.string.listen_together_playback_playing) + } else { + stringResource(R.string.listen_together_playback_paused) + } + val summaryFields = listOf( + DebugField(stringResource(R.string.listen_together_connection), stringResource(sessionState.connectionState.labelResId())), + DebugField(stringResource(R.string.listen_together_role), stringResource(roleLabelResId(role))), + DebugField(stringResource(R.string.listen_together_room_status), stringResource(roomStatusLabelResId(roomState?.roomStatus))), + DebugField(stringResource(R.string.listen_together_room_id), sessionState.roomId ?: "-"), + DebugField(stringResource(R.string.listen_together_version), roomState?.version?.toString() ?: "-"), + DebugField(stringResource(R.string.listen_together_debug_updated_at), roomState?.updatedAt?.let(::formatRoomUpdatedAtSimple) ?: "-"), + DebugField(stringResource(R.string.listen_together_members), roomState?.members?.size?.toString() ?: "0"), + DebugField(stringResource(R.string.listen_together_queue_size), roomState?.queue?.size?.toString() ?: "0"), + DebugField(stringResource(R.string.listen_together_track), roomState?.track?.name ?: fallbackTrackName ?: "-"), + DebugField(stringResource(R.string.listen_together_playback), playbackState) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DebugFieldGrid(summaryFields) + sessionState.lastError?.takeIf { it.isNotBlank() }?.let { + DebugBanner(stringResource(R.string.listen_together_last_error), it, highlighted = true) + } + sessionState.roomNotice + ?.takeIf { it.isNotBlank() && !it.startsWith("member_joined:") && !it.startsWith("member_left:") } + ?.let { DebugBanner(stringResource(R.string.listen_together_notice), it.toDisplayNotice(context)) } + } +} + +private fun resolveDisplayedPlaybackState( + roomState: ListenTogetherRoomState?, + role: String?, + isPlaying: Boolean +): String { + if (role == "controller" && roomState != null) { + return if (isPlaying) "playing" else "paused" + } + return roomState?.playback?.state ?: if (isPlaying) "playing" else "paused" +} + +@Composable +private fun SimpleMemberSection(members: List) { + if (members.isEmpty()) return + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(stringResource(R.string.listen_together_member_list_title), style = MaterialTheme.typography.titleSmall) + members.forEach { member -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = member.nickname.ifBlank { member.userUuid.ifBlank { member.userId.orEmpty() } }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(roleLabelResId(member.role)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + @Composable fun ListenTogetherDebugPanel(modifier: Modifier = Modifier) { - ListenTogetherRoomPanel(modifier = modifier, showBaseUrlEditor = true) + ListenTogetherRoomPanel(modifier = modifier, showBaseUrlEditor = true, showAdvancedDebug = true) } @Composable @@ -975,10 +1191,21 @@ private fun DebugSectionHeader( Text(text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } } - Icon( - imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = null - ) + TextButton(onClick = onToggleExpanded) { + Text( + text = stringResource( + if (expanded) { + R.string.action_collapse + } else { + R.string.action_expand + } + ) + ) + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null + ) + } } } @@ -1061,6 +1288,12 @@ private fun formatEpochDebug(value: Long): String { }.getOrDefault(value.toString()) } +private fun formatRoomUpdatedAtSimple(value: Long): String { + return runCatching { + SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(value)) + }.getOrDefault(value.toString()) +} + private fun formatDurationDebug(value: Long): String { val totalSeconds = value / 1000 val minutes = totalSeconds / 60 diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt index 31edf04c..083656a1 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/SettingsStorageCacheSection.kt @@ -132,6 +132,7 @@ internal fun SettingsStorageCacheSection( title = "晴天", artist = "周杰伦", album = "叶惠美", + source = "网易云", template = effectiveTemplate ) val canApplyDownloadFileNameTemplate = effectiveTemplate != currentSavedTemplate @@ -210,17 +211,6 @@ internal fun SettingsStorageCacheSection( ) } - val effectiveTemplate = normalizeDownloadFileNameTemplate( - pendingDownloadFileNameTemplate - ) ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE - val currentSavedTemplate = downloadFileNameTemplate ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE - val samplePreview = renderManagedDownloadBaseName( - title = "晴天", - artist = "周杰伦", - album = "叶惠美", - template = effectiveTemplate - ) - ListItem( leadingContent = { Icon( @@ -500,21 +490,7 @@ internal fun SettingsStorageCacheSection( Text( text = stringResource( R.string.settings_download_file_name_format_preview, - normalizeDownloadFileNameTemplate(pendingDownloadFileNameTemplate)?.let { template -> - renderManagedDownloadBaseName( - title = "晴天", - artist = "周杰伦", - album = "叶惠美", - template = template - ) - } ?: DEFAULT_DOWNLOAD_FILE_NAME_TEMPLATE.let { template -> - renderManagedDownloadBaseName( - title = "晴天", - artist = "周杰伦", - album = "叶惠美", - template = template - ) - } + samplePreview ), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf4d59ee..e1187dbb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1465,8 +1465,8 @@ 身份 UUID 重置唯一身份标识符 重置后旧的唯一身份标识符将失效,并替换为新的身份标识,是否继续? - 唯一身份标识符已重置。 - 当前在房间中,需先离开房间后才能重置唯一身份标识符。 + 唯一身份标识符已重置 + 当前在房间中,需先离开房间后才能重置唯一身份标识符 房间 ID 连接状态 未连接 @@ -1489,10 +1489,10 @@ 已暂停 最近错误 提示 - 房主当前离线,如果大约 %1$d 分钟内没有重连,房间会自动关闭。 - 房主已重连,同步已恢复。 - 房主长时间未返回,房间已自动关闭。 - 你已经在房间 %1$s 中,无需重复加入。 + 房主当前离线,如果大约 %1$d 分钟内没有重连,房间会自动关闭 + 房主已重连,同步已恢复 + 房主长时间未返回,房间已自动关闭 + 你已经在房间 %1$s 中,无需重复加入 创建 加入 加入房间 @@ -1506,27 +1506,27 @@ 一起听邀请 是否加入房间 %1$s? 是否加入 %1$s 的房间 %2$s? - 用户 %1$s 加入了房间。 - 用户 %1$s 离开了房间。 + 用户 %1$s 加入了房间 + 用户 %1$s 离开了房间 一起听设置 允许听众操作 - 允许听众切歌、播放暂停和拖动进度。 + 允许听众切歌、播放暂停和拖动进度 自动暂停 - 当成员进入或离开时自动暂停。 + 当成员进入或离开时自动暂停 下发音频链接 - 当可用时向听众下发可播放的音频直链。 + 当可用时向听众下发可播放的音频直链 已加入成员 - 房主已关闭听众操作权限。 - 房主当前离线,请等待重连后再试。 - 处于一起听时不可切换到本地音乐播放。 - 房间凭证已失效,请重新加入一起听。 - 房间不存在或已失效,无法继续加入。 - 一起听连接暂时中断,正在重连。 - 一起听连接已失效,正在重新加入房间。 + 房主已关闭听众操作权限 + 房主当前离线,请等待重连后再试 + 处于一起听时不可切换到本地音乐播放 + 房间凭证已失效,请重新加入一起听 + 房间不存在或已失效,无法继续加入 + 一起听连接暂时中断,正在重连 + 一起听连接已失效,正在重新加入房间 探测服务 服务探测结果:%1$s 查看协议状态、房间数据与 WebSocket 调试操作 - 用于调试一起听 demo Worker 的房间状态、WebSocket 连接和同步数据。 + 用于调试一起听 demo Worker 的房间状态、WebSocket 连接和同步数据 服务可达 WebSocket 不可用 发送 Ping @@ -1569,20 +1569,20 @@ 正在加入一起听 正在同步一起听状态 当前处于一起听 - 当前页面状态不可执行该操作,请稍后重试。 + 当前页面状态不可执行该操作,请稍后重试 配置服务器地址和唯一身份标识符 一起听服务器 - 留空即可使用内置默认服务器。 - 当前使用自定义一起听服务器,分享邀请时会附带服务器地址。 + 留空即可使用内置默认服务器 + 当前使用自定义一起听服务器,分享邀请时会附带服务器地址 服务器地址 留空使用内置默认服务器 测试可用性 - 一起听服务器设置已保存。 - 已恢复使用内置默认服务器。 - 内置默认服务器可用。 - 自定义一起听服务器可用。 - 该地址已响应,但看起来不是可用的一起听服务端。 + 一起听服务器设置已保存 + 已恢复使用内置默认服务器 + 内置默认服务器可用 + 自定义一起听服务器可用 + 该地址已响应,但看起来不是可用的一起听服务端 服务器测试失败:%1$s 正在测试服务器可用性 - 重置后将生成新的唯一身份标识符,旧标识符将失效。 + 重置后将生成新的唯一身份标识符,旧标识符将失效 From 872fc1536d54000e52755b23ec547da1b67fe9be Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 28 Mar 2026 14:26:06 +0800 Subject: [PATCH 08/11] fix(player): reduce pop noise when toggling equalizer --- .../core/player/PlaybackEffectsController.kt | 75 +++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt index 2f479767..7971dd39 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt @@ -14,11 +14,16 @@ import moe.ouom.neriplayer.core.player.model.normalizePlaybackLoudnessGainMb import moe.ouom.neriplayer.core.player.model.normalizePlaybackPitch import moe.ouom.neriplayer.core.player.model.normalizePlaybackSpeed import moe.ouom.neriplayer.core.player.model.resolvePlaybackEqualizerBandLevelsMb +import moe.ouom.neriplayer.util.NPLogger /** * 统一管理倍速、音调和均衡器,避免这些逻辑散在 PlayerManager 里 */ class PlaybackEffectsController { + companion object { + private const val TAG = "PlaybackEffects" + } + private var player: ExoPlayer? = null private var equalizer: Equalizer? = null private var equalizerSessionId: Int? = null @@ -124,12 +129,27 @@ class PlaybackEffectsController { lastKnownBandLevelRangeMb = bandRange lastEqualizerAvailable = true + NPLogger.d( + TAG, + "applyEqualizer(): sessionId=$sessionId, enabled=${config.equalizerEnabled}, preset=${config.presetId}, bands=${centersHz.size}, range=${bandRange.first}..${bandRange.last}" + ) + if (!config.equalizerEnabled) { + centersHz.forEachIndexed { index, _ -> + runCatching { + eq.setBandLevel(index.toShort(), 0) + }.onFailure { + NPLogger.w(TAG, "applyEqualizer(): failed to reset band[$index] to 0 while disabling: ${it.message}") + } + } runCatching { - if (eq.enabled) { - eq.enabled = false + if (!eq.enabled) { + eq.enabled = true } + }.onFailure { + NPLogger.w(TAG, "applyEqualizer(): failed to keep equalizer active in flat mode: ${it.message}") } + NPLogger.d(TAG, "applyEqualizer(): flattened equalizer for sessionId=$sessionId instead of disabling effect") return } @@ -139,18 +159,54 @@ class PlaybackEffectsController { bandCentersHz = centersHz, bandLevelRangeMb = bandRange ) + val equalizerHeadroomMb = resolvedLevels.maxOrNull()?.coerceAtLeast(0) ?: 0 + val appliedLevels = if (equalizerHeadroomMb > 0) { + resolvedLevels.map { it - equalizerHeadroomMb } + } else { + resolvedLevels + } - centersHz.forEachIndexed { index, _ -> + val shouldRestoreLoudnessEnhancer = + runCatching { loudnessEnhancer?.enabled == true }.getOrDefault(false) + + if (shouldRestoreLoudnessEnhancer) { runCatching { - eq.setBandLevel(index.toShort(), resolvedLevels[index].toShort()) + loudnessEnhancer?.enabled = false + }.onFailure { + NPLogger.w(TAG, "applyEqualizer(): failed to pause loudness enhancer: ${it.message}") } } runCatching { - if (!eq.enabled) { - eq.enabled = true + if (eq.enabled) { + eq.enabled = false } + }.onFailure { + NPLogger.w(TAG, "applyEqualizer(): failed to disable equalizer before reconfigure: ${it.message}") } + + centersHz.forEachIndexed { index, _ -> + runCatching { + eq.setBandLevel(index.toShort(), appliedLevels[index].toShort()) + }.onFailure { + NPLogger.w(TAG, "applyEqualizer(): failed to set band[$index]=${appliedLevels.getOrNull(index)}: ${it.message}") + } + } + + runCatching { + eq.enabled = true + }.onFailure { + NPLogger.e(TAG, "applyEqualizer(): failed to enable equalizer", it) + } + + if (shouldRestoreLoudnessEnhancer) { + applyLoudnessEnhancer() + } + + NPLogger.d( + TAG, + "applyEqualizer(): applied preset=${config.presetId}, rawMin=${resolvedLevels.minOrNull()}, rawMax=${resolvedLevels.maxOrNull()}, headroomMb=$equalizerHeadroomMb, appliedMin=${appliedLevels.minOrNull()}, appliedMax=${appliedLevels.maxOrNull()}, loudnessGainMb=${config.loudnessGainMb}" + ) } private fun applyLoudnessEnhancer() { @@ -168,8 +224,13 @@ class PlaybackEffectsController { enhancer.setTargetGain(config.loudnessGainMb) enhancer.enabled = config.loudnessGainMb > 0 lastLoudnessEnhancerAvailable = true + NPLogger.d( + TAG, + "applyLoudnessEnhancer(): sessionId=$sessionId, gainMb=${config.loudnessGainMb}, enabled=${config.loudnessGainMb > 0}" + ) }.onFailure { lastLoudnessEnhancerAvailable = false + NPLogger.e(TAG, "applyLoudnessEnhancer(): failed", it) } } @@ -184,6 +245,7 @@ class PlaybackEffectsController { Equalizer(0, sessionId).apply { enabled = false } }.getOrNull() ?: return null + NPLogger.d(TAG, "ensureEqualizer(): created equalizer for sessionId=$sessionId") equalizer = created equalizerSessionId = sessionId lastKnownBandLevelRangeMb = runCatching { created.bandLevelRange } @@ -208,6 +270,7 @@ class PlaybackEffectsController { val created = runCatching { LoudnessEnhancer(sessionId).apply { enabled = false } }.getOrNull() ?: return null + NPLogger.d(TAG, "ensureLoudnessEnhancer(): created enhancer for sessionId=$sessionId") loudnessEnhancer = created loudnessEnhancerSessionId = sessionId return created From 368a88c367695b3ef80619d012a5a74c18cdb4f1 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 28 Mar 2026 17:29:19 +0800 Subject: [PATCH 09/11] fix(listen-together): force room state sync and validate server base url --- .../listentogether/ListenTogetherApi.kt | 22 +++++++++++++-- .../listentogether/ListenTogetherInvite.kt | 4 +-- .../ListenTogetherSessionManager.kt | 27 ++++--------------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt index e730569d..f764c531 100644 --- a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherApi.kt @@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Locale data class ListenTogetherServerTestResult( val ok: Boolean, @@ -76,7 +77,11 @@ class ListenTogetherApi( } suspend fun testServerAvailability(baseUrl: String): ListenTogetherServerTestResult = withContext(Dispatchers.IO) { - val normalizedBaseUrl = baseUrl.normalizeBaseUrl() + val normalizedBaseUrl = baseUrl.normalizedHttpBaseUrlOrNull() + ?: return@withContext ListenTogetherServerTestResult( + ok = false, + message = "invalid_base_url" + ) val request = Request.Builder() .url("$normalizedBaseUrl/api/rooms/ABCDEF/state") .get() @@ -146,4 +151,17 @@ class ListenTogetherApi( } } -internal fun String.normalizeBaseUrl(): String = trim().trimEnd('/') +internal fun String.normalizeBaseUrl(): String { + return normalizedHttpBaseUrlOrNull() + ?: throw IllegalArgumentException("ListenTogether baseUrl must use http or https") +} + +internal fun String.normalizedHttpBaseUrlOrNull(): String? { + val candidate = trim().trimEnd('/').takeIf { it.isNotBlank() } ?: return null + val scheme = runCatching { android.net.Uri.parse(candidate).scheme } + .getOrNull() + ?.lowercase(Locale.ROOT) + ?: return null + if (scheme != "http" && scheme != "https") return null + return candidate +} diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt index 4fc6b457..58911c63 100644 --- a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherInvite.kt @@ -59,12 +59,12 @@ fun buildListenTogetherInviteUri( } fun resolveListenTogetherBaseUrl(value: String?): String { - val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizeBaseUrl() + val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizedHttpBaseUrlOrNull() return normalized ?: DEFAULT_LISTEN_TOGETHER_BASE_URL.normalizeBaseUrl() } fun isDefaultListenTogetherBaseUrl(value: String?): Boolean { - val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizeBaseUrl() + val normalized = value?.trim()?.takeIf { it.isNotBlank() }?.normalizedHttpBaseUrlOrNull() return normalized == DEFAULT_LISTEN_TOGETHER_BASE_URL.normalizeBaseUrl() } diff --git a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt index e41b4228..642eef43 100644 --- a/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/listentogether/ListenTogetherSessionManager.kt @@ -430,28 +430,16 @@ class ListenTogetherSessionManager( val targetIndex = state.currentIndex.coerceIn(0, queue.lastIndex) val targetSong = queue[targetIndex] - val currentQueue = PlayerManager.currentQueueFlow.value val currentSong = PlayerManager.currentSongFlow.value - val queueChanged = !currentQueue.sameQueueAs(queue) - val localIndex = currentQueue.indexOfFirst { it.sameTrackAs(targetSong) } - val targetIndexChanged = localIndex != targetIndex val needsAuthoritativeStreamReload = shouldReloadForAuthoritativeStreamUrl( targetSong = targetSong, currentSong = currentSong ) + val playbackContextChanged = true + val targetIndexChanged = true - val playbackContextChanged = - queueChanged || currentSong?.sameTrackAs(targetSong) != true || needsAuthoritativeStreamReload - - if (playbackContextChanged) { - PlayerManager.resetListenTogetherSyncPlaybackRate() - PlayerManager.playPlaylist(queue, targetIndex, commandSource = PlaybackCommandSource.REMOTE_SYNC) - } else { - if (localIndex != targetIndex) { - PlayerManager.resetListenTogetherSyncPlaybackRate() - PlayerManager.playFromQueue(targetIndex, commandSource = PlaybackCommandSource.REMOTE_SYNC) - } - } + PlayerManager.resetListenTogetherSyncPlaybackRate() + PlayerManager.playPlaylist(queue, targetIndex, commandSource = PlaybackCommandSource.REMOTE_SYNC) val resolvedExpectedPositionMs = expectedPositionMs ?: state.playback.expectedPositionMs() val localPositionMs = PlayerManager.playbackPositionFlow.value.coerceAtLeast(0L) @@ -483,7 +471,7 @@ class ListenTogetherSessionManager( applySoftDriftCorrection( driftMs = driftMs, signedDriftMs = signedDriftMs, - allowSoftSync = !queueChanged && !targetIndexChanged + allowSoftSync = false ) } if (!localPlaying) { @@ -1669,11 +1657,6 @@ private fun SongItem.sameTrackAs(other: SongItem): Boolean { resolvedPlaylistContextId() == other.resolvedPlaylistContextId() } -private fun List.sameQueueAs(other: List): Boolean { - if (size != other.size) return false - return indices.all { index -> this[index].sameTrackAs(other[index]) } -} - private fun List.toShareableQueueSnapshot( currentIndex: Int, roomSettings: ListenTogetherRoomSettings? = null, From 223fd9986df4da3230aea043c768802154439203 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Mon, 30 Mar 2026 08:30:44 +0800 Subject: [PATCH 10/11] refactor(player): unify playback service startup and optimize playback effects updates - centralize audio service sync startup behind AudioPlayerService.startSyncService - keep Android 8+ service start compatible by preferring foreground service start - refine foreground/sticky service policy with resumePlaybackRequested and buffering-aware checks - debounce quality-change refresh for Netease, YouTube Music, and Bilibili sources - optimize equalizer application by caching last applied band levels and retrying failed bands correctly - reduce redundant media session playback state updates on playback sound changes - update PlaybackSoundSheet to separate live preview from final persistence for speed, pitch, loudness, and EQ bands - keep slider and quick preset UI state in sync during interaction - adapt NowPlayingScreen callbacks to the new persist-aware playback sound API - add unit tests for playback service start policy and foreground eligibility rules --- .../ouom/neriplayer/activity/MainActivity.kt | 8 +- .../core/player/AudioPlayerService.kt | 34 ++++++- .../core/player/PlaybackEffectsController.kt | 89 +++++++++---------- .../neriplayer/core/player/PlayerManager.kt | 61 ++++++++++++- .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 17 ++-- .../ui/component/PlaybackSoundSheet.kt | 65 ++++++++++---- .../neriplayer/ui/screen/NowPlayingScreen.kt | 10 ++- .../player/AudioPlayerServicePolicyTest.kt | 22 +++++ .../PlayerManagerPlaybackStartPlanTest.kt | 30 +++++++ 9 files changed, 250 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt index 373f9516..389b5078 100644 --- a/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt +++ b/app/src/main/java/moe/ouom/neriplayer/activity/MainActivity.kt @@ -776,12 +776,10 @@ class MainActivity : ComponentActivity() { PlayerManager.initialize(application) PlayerManager.playPlaylist(result.songs, startIndex = 0) NPLogger.d("MainActivity", "Starting audio service after external audio import") - ContextCompat.startForegroundService( + AudioPlayerService.startSyncService( this@MainActivity, - AudioPlayerService.createSyncIntent( - this@MainActivity, - "external_audio_import" - ) + "external_audio_import", + forceForeground = true ) } } catch (_: CancellationException) { diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt index 535c7c61..dcaed076 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/AudioPlayerService.kt @@ -48,6 +48,7 @@ import android.util.TypedValue import androidx.annotation.DrawableRes import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap @@ -102,6 +103,16 @@ internal fun mediaSessionPlaybackActions(): Long { PlaybackStateCompat.ACTION_SEEK_TO } +internal fun shouldUseForegroundServiceStart( + sdkInt: Int, + forceForeground: Boolean, + shouldRunPlaybackServiceInForeground: Boolean +): Boolean { + return sdkInt >= Build.VERSION_CODES.O || + forceForeground || + shouldRunPlaybackServiceInForeground +} + @Suppress("unused") class AudioPlayerService : Service() { @@ -124,6 +135,25 @@ class AudioPlayerService : Service() { putExtra(EXTRA_START_SOURCE, source) } } + + fun startSyncService( + context: Context, + source: String, + forceForeground: Boolean = false + ) { + val intent = createSyncIntent(context, source) + if ( + shouldUseForegroundServiceStart( + sdkInt = Build.VERSION.SDK_INT, + forceForeground = forceForeground, + shouldRunPlaybackServiceInForeground = PlayerManager.shouldRunPlaybackServiceInForeground() + ) + ) { + ContextCompat.startForegroundService(context, intent) + } else { + context.startService(intent) + } + } } private lateinit var becomingNoisyReceiver: BroadcastReceiver @@ -140,7 +170,7 @@ class AudioPlayerService : Service() { private var lastNotificationSnapshot: PlaybackNotificationSnapshot? = null private fun shouldKeepServiceSticky(): Boolean { - return PlayerManager.hasItems() && PlayerManager.isTransportActive() + return PlayerManager.hasItems() && PlayerManager.shouldRunPlaybackServiceInForeground() } private fun buildStateSummary(): String { @@ -258,7 +288,7 @@ class AudioPlayerService : Service() { } serviceScope.launch { PlayerManager.playbackSoundStateFlow.collect { - updatePlaybackState(force = true) + updatePlaybackState() } } diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt index 7971dd39..3a9e4f13 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/PlaybackEffectsController.kt @@ -35,6 +35,8 @@ class PlaybackEffectsController { private var lastKnownBandLevelRangeMb = DEFAULT_EQUALIZER_BAND_LEVEL_RANGE_MB private var lastEqualizerAvailable = false private var lastLoudnessEnhancerAvailable = false + private var lastAppliedEqualizerLevels: List = emptyList() + private var lastAppliedEqualizerEnabled = false fun attachPlayer(player: ExoPlayer?): PlaybackSoundState { this.player = player @@ -134,73 +136,64 @@ class PlaybackEffectsController { "applyEqualizer(): sessionId=$sessionId, enabled=${config.equalizerEnabled}, preset=${config.presetId}, bands=${centersHz.size}, range=${bandRange.first}..${bandRange.last}" ) - if (!config.equalizerEnabled) { - centersHz.forEachIndexed { index, _ -> - runCatching { - eq.setBandLevel(index.toShort(), 0) - }.onFailure { - NPLogger.w(TAG, "applyEqualizer(): failed to reset band[$index] to 0 while disabling: ${it.message}") - } - } - runCatching { - if (!eq.enabled) { - eq.enabled = true - } - }.onFailure { - NPLogger.w(TAG, "applyEqualizer(): failed to keep equalizer active in flat mode: ${it.message}") - } - NPLogger.d(TAG, "applyEqualizer(): flattened equalizer for sessionId=$sessionId instead of disabling effect") - return + val resolvedLevels = if (config.equalizerEnabled) { + resolvePlaybackEqualizerBandLevelsMb( + presetId = config.presetId, + customBandLevelsMb = config.customBandLevelsMb, + bandCentersHz = centersHz, + bandLevelRangeMb = bandRange + ) + } else { + List(centersHz.size) { 0 } } - - val resolvedLevels = resolvePlaybackEqualizerBandLevelsMb( - presetId = config.presetId, - customBandLevelsMb = config.customBandLevelsMb, - bandCentersHz = centersHz, - bandLevelRangeMb = bandRange - ) val equalizerHeadroomMb = resolvedLevels.maxOrNull()?.coerceAtLeast(0) ?: 0 - val appliedLevels = if (equalizerHeadroomMb > 0) { + val appliedLevels = if (config.equalizerEnabled && equalizerHeadroomMb > 0) { resolvedLevels.map { it - equalizerHeadroomMb } } else { resolvedLevels } - val shouldRestoreLoudnessEnhancer = - runCatching { loudnessEnhancer?.enabled == true }.getOrDefault(false) - - if (shouldRestoreLoudnessEnhancer) { - runCatching { - loudnessEnhancer?.enabled = false - }.onFailure { - NPLogger.w(TAG, "applyEqualizer(): failed to pause loudness enhancer: ${it.message}") - } + if ( + lastAppliedEqualizerEnabled == config.equalizerEnabled && + lastAppliedEqualizerLevels == appliedLevels && + runCatching { eq.enabled }.getOrDefault(false) + ) { + return } - runCatching { - if (eq.enabled) { - eq.enabled = false - } - }.onFailure { - NPLogger.w(TAG, "applyEqualizer(): failed to disable equalizer before reconfigure: ${it.message}") + val updatedAppliedLevels = MutableList(appliedLevels.size) { index -> + lastAppliedEqualizerLevels.getOrNull(index) ?: Int.MIN_VALUE } - centersHz.forEachIndexed { index, _ -> + val targetLevel = appliedLevels.getOrElse(index) { 0 } + val previousLevel = lastAppliedEqualizerLevels.getOrNull(index) + if (previousLevel == targetLevel) { + updatedAppliedLevels[index] = targetLevel + return@forEachIndexed + } runCatching { - eq.setBandLevel(index.toShort(), appliedLevels[index].toShort()) + eq.setBandLevel(index.toShort(), targetLevel.toShort()) + updatedAppliedLevels[index] = targetLevel }.onFailure { - NPLogger.w(TAG, "applyEqualizer(): failed to set band[$index]=${appliedLevels.getOrNull(index)}: ${it.message}") + NPLogger.w(TAG, "applyEqualizer(): failed to set band[$index]=$targetLevel: ${it.message}") } } runCatching { - eq.enabled = true + if (!eq.enabled) { + eq.enabled = true + } }.onFailure { NPLogger.e(TAG, "applyEqualizer(): failed to enable equalizer", it) } - if (shouldRestoreLoudnessEnhancer) { - applyLoudnessEnhancer() + lastAppliedEqualizerEnabled = + config.equalizerEnabled && runCatching { eq.enabled }.getOrDefault(false) + lastAppliedEqualizerLevels = updatedAppliedLevels + + if (!config.equalizerEnabled) { + NPLogger.d(TAG, "applyEqualizer(): flattened equalizer for sessionId=$sessionId instead of disabling effect") + return } NPLogger.d( @@ -248,6 +241,8 @@ class PlaybackEffectsController { NPLogger.d(TAG, "ensureEqualizer(): created equalizer for sessionId=$sessionId") equalizer = created equalizerSessionId = sessionId + lastAppliedEqualizerLevels = emptyList() + lastAppliedEqualizerEnabled = false lastKnownBandLevelRangeMb = runCatching { created.bandLevelRange } .getOrNull() ?.takeIf { it.size >= 2 } @@ -281,6 +276,8 @@ class PlaybackEffectsController { runCatching { equalizer?.release() } equalizer = null equalizerSessionId = null + lastAppliedEqualizerLevels = emptyList() + lastAppliedEqualizerEnabled = false lastEqualizerAvailable = false } diff --git a/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt b/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt index d18b3b5c..cb51cee7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt @@ -214,6 +214,24 @@ internal fun shouldForceStartupProtectionFadeOnManualResume( currentMediaUrlResolvedAtMs <= 0L } +internal fun shouldRunPlaybackServiceInForeground( + hasCurrentSong: Boolean, + resumePlaybackRequested: Boolean, + playJobActive: Boolean, + pendingPauseJobActive: Boolean, + playWhenReady: Boolean, + isPlaying: Boolean, + playerPlaybackState: Int +): Boolean { + if (!hasCurrentSong) return false + return resumePlaybackRequested || + playJobActive || + pendingPauseJobActive || + playWhenReady || + isPlaying || + playerPlaybackState == Player.STATE_BUFFERING +} + object PlayerManager { const val BILI_SOURCE_TAG = "Bilibili" const val NETEASE_SOURCE_TAG = "Netease" @@ -250,6 +268,9 @@ object PlayerManager { private var pendingPauseJob: Job? = null private var bluetoothDisconnectPauseJob: Job? = null private var playbackSoundPersistJob: Job? = null + private var neteaseQualityRefreshJob: Job? = null + private var youtubeQualityRefreshJob: Job? = null + private var biliQualityRefreshJob: Job? = null private val localRepo: LocalPlaylistRepository get() = LocalPlaylistRepository.getInstance(application) @@ -289,6 +310,7 @@ object PlayerManager { private const val AUTO_TRANSITION_EXTERNAL_PAUSE_GUARD_MS = 2_000L private const val AUTO_TRANSITION_BUFFER_POSITION_GUARD_MS = 1_500L private const val PENDING_SEEK_POSITION_TOLERANCE_MS = 1_500L + private const val QUALITY_CHANGE_REFRESH_DEBOUNCE_MS = 300L private const val MIN_FADE_STEPS = 4 private const val MAX_FADE_STEPS = 30 @Volatile @@ -406,6 +428,20 @@ object PlayerManager { _isPlayingFlow.value } + fun shouldRunPlaybackServiceInForeground(): Boolean { + ensureInitialized() + if (!initialized || _currentSongFlow.value == null) return false + return shouldRunPlaybackServiceInForeground( + hasCurrentSong = _currentSongFlow.value != null, + resumePlaybackRequested = resumePlaybackRequested, + playJobActive = playJob?.isActive == true, + pendingPauseJobActive = pendingPauseJob?.isActive == true, + playWhenReady = _playWhenReadyFlow.value, + isPlaying = _isPlayingFlow.value, + playerPlaybackState = _playerPlaybackStateFlow.value + ) + } + fun isTransportBuffering(): Boolean { ensureInitialized() if (!initialized || !isTransportActive()) return false @@ -897,6 +933,25 @@ object PlayerManager { } } + private fun scheduleQualityRefresh( + source: PlaybackAudioSource, + reason: String + ) { + val targetJob = when (source) { + PlaybackAudioSource.NETEASE -> ::neteaseQualityRefreshJob + PlaybackAudioSource.YOUTUBE_MUSIC -> ::youtubeQualityRefreshJob + PlaybackAudioSource.BILIBILI -> ::biliQualityRefreshJob + PlaybackAudioSource.LOCAL -> return + } + targetJob.get()?.cancel() + targetJob.set( + ioScope.launch { + delay(QUALITY_CHANGE_REFRESH_DEBOUNCE_MS) + refreshCurrentSongForQualityChange(source = source, reason = reason) + } + ) + } + private suspend fun refreshCurrentSongForQualityChange( source: PlaybackAudioSource, reason: String @@ -1294,7 +1349,7 @@ object PlayerManager { val previousQuality = preferredQuality preferredQuality = q if (previousQuality != q) { - refreshCurrentSongForQualityChange( + scheduleQualityRefresh( source = PlaybackAudioSource.NETEASE, reason = "netease_quality_changed" ) @@ -1306,7 +1361,7 @@ object PlayerManager { val previousQuality = youtubePreferredQuality youtubePreferredQuality = q if (previousQuality != q) { - refreshCurrentSongForQualityChange( + scheduleQualityRefresh( source = PlaybackAudioSource.YOUTUBE_MUSIC, reason = "youtube_quality_changed" ) @@ -1318,7 +1373,7 @@ object PlayerManager { val previousQuality = biliPreferredQuality biliPreferredQuality = q if (previousQuality != q) { - refreshCurrentSongForQualityChange( + scheduleQualityRefresh( source = PlaybackAudioSource.BILIBILI, reason = "bili_quality_changed" ) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt index 2a74f3c7..8f292529 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt @@ -615,12 +615,9 @@ fun NeriApp( "NERI-App", "Player bootstrap state hasItems=${PlayerManager.hasItems()} transportActive=${PlayerManager.isTransportActive()} isPlaying=${PlayerManager.isPlayingFlow.value}" ) - if (PlayerManager.hasItems() && PlayerManager.isTransportActive()) { + if (PlayerManager.hasItems() && PlayerManager.shouldRunPlaybackServiceInForeground()) { NPLogger.d("NERI-App", "Starting audio service from app bootstrap") - ContextCompat.startForegroundService( - context, - AudioPlayerService.createSyncIntent(context, "app_bootstrap") - ) + AudioPlayerService.startSyncService(context, "app_bootstrap", forceForeground = true) } else { NPLogger.d("NERI-App", "Skip audio service bootstrap because transport is inactive") } @@ -723,9 +720,10 @@ fun NeriApp( // 播放队列可能包含歌词等大字段,避免通过 Binder 传整份歌单导致崩溃 PlayerManager.playPlaylist(songs, index) NPLogger.d("NERI-App", "Starting audio service after playSongsAndOpenNowPlaying") - ContextCompat.startForegroundService( + AudioPlayerService.startSyncService( context, - AudioPlayerService.createSyncIntent(context, "play_songs_and_open_now_playing") + "play_songs_and_open_now_playing", + forceForeground = true ) } @@ -734,10 +732,7 @@ fun NeriApp( "NERI-App", "ensureAudioServiceStarted hasItems=${PlayerManager.hasItems()} transportActive=${PlayerManager.isTransportActive()} isPlaying=${PlayerManager.isPlayingFlow.value}" ) - ContextCompat.startForegroundService( - context, - AudioPlayerService.createSyncIntent(context, "ensure_audio_service_started") - ) + AudioPlayerService.startSyncService(context, "ensure_audio_service_started") } fun playBiliAudioAndOpenNowPlaying(videos: List, index: Int) { diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackSoundSheet.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackSoundSheet.kt index 49808c2a..c0cc3604 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackSoundSheet.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackSoundSheet.kt @@ -24,6 +24,11 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -61,12 +66,12 @@ private const val EQUALIZER_SLIDER_STEP_MB = 50 @Composable fun PlaybackSoundSheet( state: PlaybackSoundState, - onSpeedChange: (Float) -> Unit, - onPitchChange: (Float) -> Unit, - onLoudnessGainChange: (Int) -> Unit, + onSpeedChange: (Float, Boolean) -> Unit, + onPitchChange: (Float, Boolean) -> Unit, + onLoudnessGainChange: (Int, Boolean) -> Unit, onEqualizerEnabledChange: (Boolean) -> Unit, onPresetSelected: (String) -> Unit, - onBandLevelChange: (Int, Int) -> Unit, + onBandLevelChange: (Int, Int, Boolean) -> Unit, onReset: () -> Unit, onDismiss: () -> Unit ) { @@ -162,15 +167,22 @@ fun PlaybackSoundSheet( style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace) ) } + var loudnessSliderValue by remember(state.loudnessGainMb) { + mutableIntStateOf(state.loudnessGainMb) + } Slider( - value = state.loudnessGainMb.toFloat(), + value = loudnessSliderValue.toFloat(), onValueChange = { raw -> val normalized = ((raw / LOUDNESS_SLIDER_STEP_MB).roundToInt() * LOUDNESS_SLIDER_STEP_MB) .coerceIn( minimumValue = MIN_PLAYBACK_LOUDNESS_GAIN_MB, maximumValue = MAX_PLAYBACK_LOUDNESS_GAIN_MB ) - onLoudnessGainChange(normalizePlaybackLoudnessGainMb(normalized)) + loudnessSliderValue = normalizePlaybackLoudnessGainMb(normalized) + onLoudnessGainChange(loudnessSliderValue, false) + }, + onValueChangeFinished = { + onLoudnessGainChange(loudnessSliderValue, true) }, valueRange = MIN_PLAYBACK_LOUDNESS_GAIN_MB.toFloat()..MAX_PLAYBACK_LOUDNESS_GAIN_MB.toFloat(), steps = buildDiscreteSliderSteps( @@ -184,8 +196,11 @@ fun PlaybackSoundSheet( ) { LOUDNESS_QUICK_PRESETS.forEach { preset -> FilterChip( - selected = state.loudnessGainMb == preset, - onClick = { onLoudnessGainChange(preset) }, + selected = loudnessSliderValue == preset, + onClick = { + loudnessSliderValue = preset + onLoudnessGainChange(loudnessSliderValue, true) + }, label = { Text(formatPlaybackGainLabel(preset)) } ) } @@ -264,6 +279,9 @@ fun PlaybackSoundSheet( ) state.bands.forEach { band -> + var bandSliderValue by remember(band.index, band.levelMb) { + mutableIntStateOf(band.levelMb) + } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -280,14 +298,18 @@ fun PlaybackSoundSheet( ) } Slider( - value = band.levelMb.toFloat(), + value = bandSliderValue.toFloat(), onValueChange = { raw -> val normalized = ((raw / EQUALIZER_SLIDER_STEP_MB).roundToInt() * EQUALIZER_SLIDER_STEP_MB) .coerceIn( minimumValue = state.bandLevelRangeMb.first, maximumValue = state.bandLevelRangeMb.last ) - onBandLevelChange(band.index, normalized) + bandSliderValue = normalized + onBandLevelChange(band.index, bandSliderValue, false) + }, + onValueChangeFinished = { + onBandLevelChange(band.index, bandSliderValue, true) }, valueRange = state.bandLevelRangeMb.first.toFloat()..state.bandLevelRangeMb.last.toFloat(), steps = buildDiscreteSliderSteps( @@ -328,8 +350,11 @@ private fun PlaybackControlCard( range: ClosedFloatingPointRange, steps: Int, normalize: (Float) -> Float, - onValueChange: (Float) -> Unit + onValueChange: (Float, Boolean) -> Unit ) { + var sliderValue by remember(currentValue) { + mutableFloatStateOf(currentValue) + } Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f) @@ -356,8 +381,14 @@ private fun PlaybackControlCard( ) } Slider( - value = currentValue, - onValueChange = { onValueChange(normalize(it)) }, + value = sliderValue, + onValueChange = { + sliderValue = normalize(it) + onValueChange(sliderValue, false) + }, + onValueChangeFinished = { + onValueChange(sliderValue, true) + }, valueRange = range, steps = steps ) @@ -366,9 +397,13 @@ private fun PlaybackControlCard( verticalArrangement = Arrangement.spacedBy(8.dp) ) { quickPresets.forEach { preset -> + val normalizedPreset = normalize(preset) FilterChip( - selected = abs(currentValue - preset) < 0.001f, - onClick = { onValueChange(normalize(preset)) }, + selected = abs(sliderValue - normalizedPreset) < 0.001f, + onClick = { + sliderValue = normalizedPreset + onValueChange(sliderValue, true) + }, label = { Text(formatMultiplier(preset)) } ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt index 0e38a74c..457c5892 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreen.kt @@ -2024,12 +2024,14 @@ fun MoreOptionsSheet( "PlaybackSound" -> { PlaybackSoundSheet( state = playbackSoundState, - onSpeedChange = viewModel::setPlaybackSpeed, - onPitchChange = viewModel::setPlaybackPitch, - onLoudnessGainChange = viewModel::setPlaybackLoudnessGain, + onSpeedChange = { value, persist -> viewModel.setPlaybackSpeed(value, persist) }, + onPitchChange = { value, persist -> viewModel.setPlaybackPitch(value, persist) }, + onLoudnessGainChange = { value, persist -> viewModel.setPlaybackLoudnessGain(value, persist) }, onEqualizerEnabledChange = viewModel::setPlaybackEqualizerEnabled, onPresetSelected = viewModel::selectPlaybackEqualizerPreset, - onBandLevelChange = viewModel::updatePlaybackEqualizerBandLevel, + onBandLevelChange = { index, value, persist -> + viewModel.updatePlaybackEqualizerBandLevel(index, value, persist) + }, onReset = viewModel::resetPlaybackSoundSettings, onDismiss = { showPlaybackSoundSheet = false } ) diff --git a/app/src/test/java/moe/ouom/neriplayer/core/player/AudioPlayerServicePolicyTest.kt b/app/src/test/java/moe/ouom/neriplayer/core/player/AudioPlayerServicePolicyTest.kt index a97c7568..b02bd8dc 100644 --- a/app/src/test/java/moe/ouom/neriplayer/core/player/AudioPlayerServicePolicyTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/core/player/AudioPlayerServicePolicyTest.kt @@ -39,4 +39,26 @@ class AudioPlayerServicePolicyTest { assertTrue(actions and PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS != 0L) assertTrue(actions and PlaybackStateCompat.ACTION_SEEK_TO != 0L) } + + @Test + fun `android o and above always use foreground service start`() { + assertTrue( + shouldUseForegroundServiceStart( + sdkInt = 26, + forceForeground = false, + shouldRunPlaybackServiceInForeground = false + ) + ) + } + + @Test + fun `pre o can use background start when foreground is unnecessary`() { + assertFalse( + shouldUseForegroundServiceStart( + sdkInt = 25, + forceForeground = false, + shouldRunPlaybackServiceInForeground = false + ) + ) + } } diff --git a/app/src/test/java/moe/ouom/neriplayer/core/player/PlayerManagerPlaybackStartPlanTest.kt b/app/src/test/java/moe/ouom/neriplayer/core/player/PlayerManagerPlaybackStartPlanTest.kt index b01d8abd..fb17c769 100644 --- a/app/src/test/java/moe/ouom/neriplayer/core/player/PlayerManagerPlaybackStartPlanTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/core/player/PlayerManagerPlaybackStartPlanTest.kt @@ -103,4 +103,34 @@ class PlayerManagerPlaybackStartPlanTest { assertFalse(shouldProtect) } + + @Test + fun `resume requested keeps playback service eligible for foreground`() { + val shouldRunInForeground = shouldRunPlaybackServiceInForeground( + hasCurrentSong = true, + resumePlaybackRequested = true, + playJobActive = false, + pendingPauseJobActive = false, + playWhenReady = false, + isPlaying = false, + playerPlaybackState = 0 + ) + + assertTrue(shouldRunInForeground) + } + + @Test + fun `foreground service policy requires current song`() { + val shouldRunInForeground = shouldRunPlaybackServiceInForeground( + hasCurrentSong = false, + resumePlaybackRequested = true, + playJobActive = true, + pendingPauseJobActive = true, + playWhenReady = true, + isPlaying = true, + playerPlaybackState = 0 + ) + + assertFalse(shouldRunInForeground) + } } From cf61afba81938bf56cca43c8ee8f9abc1a66f4e9 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Thu, 2 Apr 2026 20:04:01 +0800 Subject: [PATCH 11/11] fix(ui): harden NetEase auth storage and optimize now playing backdrop refresh --- .gitignore | 2 + .idea/deploymentTargetSelector.xml | 4 +- .../neriplayer/core/player/PlayerManager.kt | 32 +++- .../auth/netease/NeteaseCookieRepository.kt | 140 +++++++++++++-- .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 166 +++++++++--------- .../neriplayer/ui/view/HyperBackground.kt | 9 +- .../viewmodel/debug/NeteaseAuthViewModel.kt | 22 ++- .../neriplayer/util/CoverArtColorCache.kt | 99 +++++++++++ .../data/NeteaseAuthRepositoryTest.kt | 31 ++++ 9 files changed, 389 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/moe/ouom/neriplayer/util/CoverArtColorCache.kt diff --git a/.gitignore b/.gitignore index 7ecdf495..b10276ea 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ local.properties /.tmp/ /tools/ /.kotlin/ +demo +.idea \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 5c4d1018..223ba75c 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,10 +4,10 @@