From e7ed30444644544f92c94941ee86ee69946548bd Mon Sep 17 00:00:00 2001 From: cwuom Date: Sat, 31 Oct 2026 19:44:35 +0800 Subject: [PATCH] fix(local): stabilize metadata editing and playback refresh --- app/src/androidTest/assets/metadata/README.md | 27 + .../metadata/synthetic-playing-restore.flac | Bin 0 -> 14066 bytes .../RestoredLocalPlaybackThreadTest.kt | 519 ++++++++++++ .../java/moe/ouom/neriplayer/ui/NeriApp.kt | 3 +- .../ui/animation/SharedCoverFlight.kt | 8 +- .../ui/component/PlaybackCompatibility.kt | 6 +- .../ManualMetadataApplicationProgress.kt | 134 +++ .../playback/ManualMetadataApplyOptions.kt | 305 +++++++ .../ui/component/playback/NeriMiniPlayer.kt | 19 +- .../playback/SongMetadataSearchContent.kt | 200 ++++- .../ui/navigation/AppNavigationScaffold.kt | 2 + .../playback/visual/AppNowPlayingBackdrop.kt | 18 +- .../playback/visual/AppNowPlayingOverlay.kt | 20 +- .../playback/visual/AppPlaybackVisualOwner.kt | 17 +- .../visual/NowPlayingBackdropLayers.kt | 9 +- .../visual/NowPlayingBlurEffectOwner.kt | 2 +- .../ui/playback/visual/NowPlayingBlurState.kt | 13 +- .../ui/screen/nowplaying/NowPlayingScreen.kt | 6 +- .../nowplaying/NowPlayingSecondaryActions.kt | 33 +- .../nowplaying/cover/NowPlayingCover.kt | 55 +- .../edit/NowPlayingSongEditOwner.kt | 20 +- .../edit/NowPlayingSongEditPolicy.kt | 70 +- .../edit/NowPlayingSongEditSheet.kt | 2 +- .../ouom/neriplayer/ui/util/CoverUrlState.kt | 4 + .../ManualMetadataApplicationOwner.kt | 68 ++ .../ui/viewmodel/NowPlayingViewModel.kt | 33 +- .../strings_metadata_application.xml | 19 + .../strings_metadata_application.xml | 19 + .../values/strings_metadata_application.xml | 19 + .../ui/AppNowPlayingOverlayArtworkTest.kt | 78 ++ .../ui/AppPlaybackCoverSourceTest.kt | 46 + .../ui/AppPlaybackVisualOwnerTest.kt | 41 + .../ui/NowPlayingBlurEffectOwnerTest.kt | 42 + .../neriplayer/ui/NowPlayingBlurStateTest.kt | 32 + .../ManualMetadataApplicationProgressTest.kt | 160 ++++ .../ManualMetadataApplicationSheetTest.kt | 147 ++++ .../playback/ManualMetadataApplyDialogTest.kt | 273 ++++++ .../ManualMetadataApplyOptionsTest.kt | 51 ++ ...NeriMiniPlayerHighResolutionArtworkTest.kt | 94 ++- .../visual/NowPlayingBackdropArtworkTest.kt | 181 ++++ ...MetadataWriteBackConfirmationPolicyTest.kt | 73 +- .../ui/screen/NowPlayingCoverOwnerTest.kt | 63 ++ .../ui/screen/NowPlayingScreenTest.kt | 17 +- .../ui/screen/NowPlayingSongEditOwnerTest.kt | 227 ++++- .../ui/screen/NowPlayingSongEditPolicyTest.kt | 61 ++ .../MoreOptionsSheetBackTargetTest.kt | 20 + .../ui/util/CoverUrlStateComposeTest.kt | 121 +++ .../ManualMetadataApplicationOwnerTest.kt | 101 +++ .../ManagedDownloadedAudioMetadataDecoder.kt | 3 +- .../ManagedDownloadRestorableMetadataJson.kt | 9 +- ...nagedDownloadedAudioMetadataDecoderTest.kt | 46 +- .../ManagedDownloadRestorableMetadataTest.kt | 14 + .../download/catalog/DownloadedSongBuilder.kt | 4 +- .../catalog/DownloadedSongCatalogModels.kt | 3 +- .../download/policy/GlobalDownloadPolicies.kt | 1 + .../GlobalDownloadManagerStartupPolicyTest.kt | 28 + .../DownloadedSongBuilderAssemblyTest.kt | 28 + .../DownloadedSongBuilderCoverPolicyTest.kt | 3 + .../DownloadedSongCatalogProjectionTest.kt | 18 + modules/local/README.md | 8 +- .../src/androidTest/assets/metadata/README.md | 7 + .../assets/metadata/synthetic-edit.flac | Bin 0 -> 70902 bytes .../media/LocalMetadataEditRoundTripTest.kt | 142 ++++ .../LocalMetadataRecoveryThroughputTest.kt | 80 ++ .../local/media/CustomSongCoverStorage.kt | 22 +- .../local/media/LocalMediaSupportContracts.kt | 1 + .../media/LocalMediaSupportFacadeMetadata.kt | 43 +- .../local/media/LocalMediaSupportLyrics.kt | 4 +- .../LocalMediaMetadataRecoveryStore.kt | 4 +- .../local/media/CustomSongCoverStorageTest.kt | 39 + .../LocalMediaRestorableSidecarEditTest.kt | 124 +++ .../repository/QQMusicLyricsRepository.kt | 2 + .../search/api/client/CloudMusicSearchApi.kt | 28 +- .../repository/QQMusicLyricsRepositoryTest.kt | 13 + .../api/client/CloudMusicSearchApiTest.kt | 13 + modules/playback/runtime/README.md | 14 + .../neriplayer/core/player/PlayerManager.kt | 8 +- .../player/metadata/PlayerMetadataHelper.kt | 13 +- .../metadata/SelectedSongMetadataLyrics.kt | 140 ++++ .../metadata/SongMetadataApplyOptions.kt | 24 + .../PlayerManagerManualMetadataApply.kt | 151 ++++ .../PlayerManagerMetadataBaseline.kt | 4 +- .../PlayerManagerPersistenceExtensions.kt | 107 ++- .../PlayerManagerPlaybackExtensions.kt | 53 +- .../core/player/service/AudioPlayerService.kt | 5 +- .../service/artwork/PlaybackArtworkOwner.kt | 11 + .../AndroidPlaybackServicePresentationPort.kt | 27 +- .../PlaybackNotificationPublicationOwner.kt | 94 +++ .../PlaybackServicePresentationOwner.kt | 23 +- ...rManagerCustomMetadataNormalizationTest.kt | 28 + .../SelectedSongMetadataLyricsTest.kt | 131 +++ ...rManagerLocalMetadataEditRegressionTest.kt | 790 +++++++++++++++++- .../AudioPlayerServiceColorOsLyricsTest.kt | 5 + .../artwork/PlaybackArtworkOwnerTest.kt | 107 +++ ...roidPlaybackNotificationPublicationTest.kt | 144 ++++ ...ybackNotificationDeliveryRegressionTest.kt | 265 ++++++ .../PlaybackServiceMatchedFavoritesTest.kt | 8 + .../PlaybackServicePresentationOwnerTest.kt | 13 + 98 files changed, 6013 insertions(+), 317 deletions(-) create mode 100644 app/src/androidTest/assets/metadata/README.md create mode 100644 app/src/androidTest/assets/metadata/synthetic-playing-restore.flac create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgress.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptions.kt create mode 100644 app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwner.kt create mode 100644 app/src/main/res/values-en/strings_metadata_application.xml create mode 100644 app/src/main/res/values-zh/strings_metadata_application.xml create mode 100644 app/src/main/res/values/strings_metadata_application.xml create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackCoverSourceTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgressTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationSheetTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyDialogTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptionsTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropArtworkTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/util/CoverUrlStateComposeTest.kt create mode 100644 app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwnerTest.kt create mode 100644 modules/local/src/androidTest/assets/metadata/README.md create mode 100644 modules/local/src/androidTest/assets/metadata/synthetic-edit.flac create mode 100644 modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMetadataEditRoundTripTest.kt create mode 100644 modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMetadataRecoveryThroughputTest.kt create mode 100644 modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaRestorableSidecarEditTest.kt create mode 100644 modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyrics.kt create mode 100644 modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SongMetadataApplyOptions.kt create mode 100644 modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerManualMetadataApply.kt create mode 100644 modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationPublicationOwner.kt create mode 100644 modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyricsTest.kt create mode 100644 modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackNotificationPublicationTest.kt create mode 100644 modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationDeliveryRegressionTest.kt diff --git a/app/src/androidTest/assets/metadata/README.md b/app/src/androidTest/assets/metadata/README.md new file mode 100644 index 0000000000..19b3ab57ce --- /dev/null +++ b/app/src/androidTest/assets/metadata/README.md @@ -0,0 +1,27 @@ +# Synthetic playback metadata fixture + +`synthetic-playing-restore.flac` contains 30 seconds of generated mono silence, +original title/artist and three independent lyric tracks, with no artwork. +It contains no third-party audio or lyrics. + +Regenerate from the repository root with FFmpeg: + +```sh +ffmpeg -hide_banner -loglevel error \ + -f lavfi -i anullsrc=r=16000:cl=mono -t 30 \ + -c:a flac -sample_fmt s16 -map_metadata -1 \ + -metadata title='Original title' \ + -metadata artist='Original artist' \ + -metadata album='Synthetic restore fixture' \ + -metadata 'LYRICS=[00:01.00]original lyric' \ + -metadata 'NERI_LYRICS_ORIGINAL=[00:01.00]original lyric' \ + -metadata 'LYRICS_TRANSLATED=[00:01.00]original translation' \ + -metadata 'NERI_LYRICS_ROMANIZED=[00:01.00]original romanization' \ + app/src/androidTest/assets/metadata/synthetic-playing-restore.flac +``` + +`RestoredLocalPlaybackThreadTest.singleRestoreWhilePlayingPublishesOriginalMetadataToSessionAndNotification` +runs only on an API 37 emulator with notifications allowed and no existing +playback, playback service, sleep timer or sync target. It uses a private copy, +restores the saved queue and runtime preferences, and removes only its synthetic +history/statistics entries and temporary media files. diff --git a/app/src/androidTest/assets/metadata/synthetic-playing-restore.flac b/app/src/androidTest/assets/metadata/synthetic-playing-restore.flac new file mode 100644 index 0000000000000000000000000000000000000000..58482e7fad23a8aa5da5375c41f4ea4abeb289c8 GIT binary patch literal 14066 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 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt index 92af533cb9..da5f597d8a 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt @@ -7,6 +7,12 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.graphics.Bitmap +import android.graphics.Color +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSession +import android.media.session.PlaybackState import android.net.Uri import android.os.Build import android.os.IBinder @@ -14,7 +20,11 @@ import android.os.Looper import android.os.StrictMode import android.os.SystemClock import androidx.core.content.ContextCompat +import androidx.core.os.BundleCompat +import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -25,16 +35,33 @@ import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import moe.ouom.neriplayer.core.di.AppContainer import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.core.player.host.PlayerDependencies import moe.ouom.neriplayer.core.player.persistence.PlaybackQueueRoomStore import moe.ouom.neriplayer.core.player.persistence.RestoredPlayerStateSnapshot +import moe.ouom.neriplayer.core.player.persistence.stats.flushPlaybackStatsPendingWrites import moe.ouom.neriplayer.core.player.service.AudioPlayerService import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome +import moe.ouom.neriplayer.data.local.media.displayArtist +import moe.ouom.neriplayer.data.local.media.displayName import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.playback.PersistedState import moe.ouom.neriplayer.data.settings.playback.readPlaybackPreferenceSnapshotSync +import moe.ouom.neriplayer.data.stats.PlaybackStatsCursor +import moe.ouom.neriplayer.data.stats.PlaybackStatsQuery +import moe.ouom.neriplayer.data.sync.store.github.SecureTokenStorage +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongRestoreSelection +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.NowPlayingSongEditOwner +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.PlayerManagerNowPlayingSongEditPlaybackPort +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveEditSongBaselineFromSong +import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -484,6 +511,492 @@ class RestoredLocalPlaybackThreadTest { } } + @Test + fun singleRestoreWhilePlayingPublishesOriginalMetadataToSessionAndNotification() { + listOf(false, true).forEach(::verifySingleRestoreWhilePlaying) + } + + @Test + fun titleAndArtistOnlyEditsWhilePausedReachSessionAndNotificationAfterResuming() { + verifySingleRestoreWhilePlaying(notificationLyricsEnabled = true, metadataOnly = true) + } + + private fun verifySingleRestoreWhilePlaying(notificationLyricsEnabled: Boolean) = + verifySingleRestoreWhilePlaying(notificationLyricsEnabled, metadataOnly = false) + + private fun verifySingleRestoreWhilePlaying(notificationLyricsEnabled: Boolean, metadataOnly: Boolean) { + assumeTrue("仅在 API 37 模拟器运行", Build.VERSION.SDK_INT == 37 && + Build.HARDWARE in setOf("ranchu", "goldfish")) + assumeFalse("已有服务时不干预其生命周期", AudioPlayerService.isInstanceActiveForDiagnostics()) + assumeFalse("已有播放时不干预播放器", PlayerManager.isPlayingFlow.value || + PlayerManager.playWhenReadyFlow.value || PlayerManager.playbackControlPlayingFlow.value) + assumeFalse("已有睡眠定时器时不干预播放", PlayerManager.sleepTimerManager.timerState.value.isActive) + assumeFalse("安全模式不创建播放服务", PlayerDependencies.presentation.shouldEnterSafeMode(context)) + assumeNoFixtureSyncTargets() + val notifications = checkNotNull(context.getSystemService(NotificationManager::class.java)) + assumeTrue("必须允许真实媒体通知", notifications.areNotificationsEnabled()) + assumeTrue("媒体通知渠道不能被关闭", notifications.getNotificationChannel(PlaybackNotificationChannel) + ?.importance != NotificationManager.IMPORTANCE_NONE) + val app = context.applicationContext as Application + val preferences = readPlaybackPreferenceSnapshotSync(app) + val savedQueue = runBlocking { SavedPlaybackQueue.read(context) } + val settings = AppContainer.settingsRepo + val savedLyricSettings = runBlocking { NotificationLyricSettingsSnapshot( + settings.dynamicIslandLyricsEnabledFlow.first(), settings.externalBluetoothLyricsEnabledFlow.first(), + settings.externalBluetoothTranslationEnabledFlow.first(), settings.colorOsLockscreenLyricsEnabledFlow.first() + ) } + val directory = File(context.cacheDir, "playing-metadata-restore-${UUID.randomUUID()}") + val fixtureSongs = mutableListOf() + val extractedCovers = mutableSetOf() + var connection: ServiceConnection? = null + var ownsService = false + var failure: Throwable? = null + try { + instrumentation.runOnMainSync { PlayerManager.release() } + runBlocking { + settings.setDynamicIslandLyricsEnabled(notificationLyricsEnabled) + settings.setExternalBluetoothLyricsEnabled(notificationLyricsEnabled) + settings.setExternalBluetoothTranslationEnabled(notificationLyricsEnabled) + settings.setColorOsLockscreenLyricsEnabled(false) + } + check(directory.mkdirs()) + val audio = File(directory, "synthetic-playing-restore.flac") + instrumentation.context.assets.open("metadata/synthetic-playing-restore.flac").use { input -> + audio.outputStream().use { output -> input.copyTo(output) } + } + val cover = File(directory, "edited-cover.png") + Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888).let { bitmap -> + try { + bitmap.eraseColor(Color.rgb(227, 43, 70)) + cover.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + bitmap.recycle() + } + } + val originalTranslation = if (notificationLyricsEnabled && !metadataOnly) "" else OriginalTranslation + val original = SongItem( + id = -System.nanoTime(), name = OriginalTitle, artist = OriginalArtist, + album = "Synthetic restore fixture", albumId = 0L, durationMs = 30_000L, coverUrl = null, + mediaUri = Uri.fromFile(audio).toString(), localFilePath = audio.path, localFileName = audio.name, + channelId = "local", audioId = audio.name, originalName = OriginalTitle, + originalArtist = OriginalArtist, originalCoverUrl = "", matchedLyric = OriginalLyric, + matchedTranslatedLyric = originalTranslation, matchedRomanizedLyric = OriginalRomanization, + originalLyric = OriginalLyric, originalTranslatedLyric = originalTranslation, + originalRomanizedLyric = OriginalRomanization, lyricSyncEdited = false + ) + fixtureSongs += original + if (notificationLyricsEnabled) { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, runBlocking { + LocalMediaSupport.writeEditableMetadata(app, original, writeLyrics = true) + }) + } + val embedded = checkNotNull(LocalMediaSupport.inspectEmbeddedLyrics(app, original)) + assertTrue(embedded.embeddedSourceResolved) + assertEquals(OriginalLyric, embedded.lyric) + assertEquals(originalTranslation, embedded.translatedLyric.orEmpty()) + assertEquals(OriginalRomanization, embedded.romanizedLyric) + instrumentation.runOnMainSync { + PlayerManager.initializePreloaded(app, preferences.copy( + keepLastPlaybackProgress = false, maxCacheSizeBytes = 0L, usbExclusivePlayback = false, + playbackFadeIn = false, playbackCrossfadeNext = false, playbackSpeed = 1f, + amllLyricsEnabled = false + ), restored(original).copy(resumePositionMs = 0L, shouldResumePlayback = false, repeatMode = 1)) + } + ActivityScenario.launch(TestComposeActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val intent = Intent(activity, AudioPlayerService::class.java) + .setAction(AudioPlayerService.ACTION_PLAY) + .putParcelableArrayListExtra("playlist", arrayListOf(original)) + .putExtra("index", 0) + .putExtra(AudioPlayerService.EXTRA_START_SOURCE, "playing_metadata_restore_test") + assertNotNull(ContextCompat.startForegroundService(activity, intent)) + ownsService = true + } + awaitServiceState("合成 FLAC 必须进入真实播放") { + AudioPlayerService.isForegroundActiveForDiagnostics() && + PlayerManager.currentSongFlow.value?.id == original.id && + PlayerManager.isPlayingFlow.value && PlayerManager.playbackPositionFlow.value > 0L + } + val token = CompletableFuture() + val client = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + try { + token.complete(checkNotNull(binder).javaClass.getMethod("getSessionToken") + .invoke(binder) as? MediaSession.Token ?: error("播放服务没有媒体会话")) + } catch (error: Exception) { + token.completeExceptionally(error) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + token.completeExceptionally(AssertionError("播放服务绑定中断")) + } + } + instrumentation.runOnMainSync { + assertTrue(context.bindService(Intent(context, AudioPlayerService::class.java) + .setAction(ActionBindCar), client, Context.BIND_AUTO_CREATE)) + connection = client + } + val controller = MediaController(context, token.get(ServiceTimeoutSeconds, TimeUnit.SECONDS)) + assertEquals(context.packageName, controller.packageName) + listOf(false, true).forEachIndexed { round, writeLocalMetadata -> + instrumentation.runOnMainSync { PlayerManager.seekTo(0L) } + val editedTitle = "Edited title $round" + val editedArtist = "Edited artist $round" + val editedLyrics = if (metadataOnly) listOf(OriginalLyric, originalTranslation, OriginalRomanization) + else listOf("[00:01.00]edited lyric $round", + if (notificationLyricsEnabled) "" else "[00:01.00]edited translation $round", + "[00:01.00]edited romanization $round") + if (metadataOnly) { + instrumentation.runOnMainSync { PlayerManager.pause(forcePersist = false) } + awaitServiceState("仅编辑名称前必须暂停") { !PlayerManager.isPlayingFlow.value } + saveFixtureNamesOnce(scenario, checkNotNull(PlayerManager.currentSongFlow.value), + editedTitle, editedArtist, writeLocalMetadata) + instrumentation.runOnMainSync { PlayerManager.play() } + } else runBlocking { + assertTrue(PlayerManager.updateSongLyricsAndTranslation( + songToUpdate = checkNotNull(PlayerManager.currentSongFlow.value), + newLyrics = editedLyrics[0], newTranslatedLyrics = editedLyrics[1], + newRomanizedLyrics = editedLyrics[2], writeLocalMetadata = true + )) + assertTrue(PlayerManager.updateSongCustomInfo( + originalSong = checkNotNull(PlayerManager.currentSongFlow.value), + customCoverUrl = Uri.fromFile(cover).toString(), customName = editedTitle, + customArtist = editedArtist, writeLocalMetadata = true, writeLyrics = true + )) + } + awaitPlayingMetadata("通知歌词=$notificationLyricsEnabled,第 $round 轮修改必须到达当前歌曲、媒体会话和系统通知", + controller, notifications, original.id, editedTitle, editedArtist, editedLyrics, + hasArtwork = !metadataOnly, notificationLyricsEnabled) + LocalMediaSupport.peekCachedEmbeddedCoverUri(app, original)?.let { reference -> + Uri.parse(reference).path?.let { extractedCovers += File(it) } + } + val edited = checkNotNull(PlayerManager.currentSongFlow.value) + restorePlayingFixtureOnce(scenario, edited, writeLocalMetadata) + val originalLyrics = listOf(OriginalLyric, originalTranslation, OriginalRomanization) + awaitPlayingMetadata("通知歌词=$notificationLyricsEnabled,第 $round 轮一次恢复保存必须刷新全部真实播放展示", + controller, notifications, original.id, OriginalTitle, OriginalArtist, originalLyrics, + hasArtwork = false, notificationLyricsEnabled) + val stableUntil = SystemClock.elapsedRealtime() + 1_500L + while (SystemClock.elapsedRealtime() < stableUntil) { + instrumentation.runOnMainSync { + if (!playingMetadataMatches(controller, notifications, original.id, OriginalTitle, + OriginalArtist, originalLyrics, hasArtwork = false, notificationLyricsEnabled)) { + throw AssertionError("恢复值不能被延迟的旧快照重新覆盖\n" + playingMetadataDiagnostics( + controller, notifications, original.id, OriginalTitle, OriginalArtist, + originalLyrics, hasArtwork = false, notificationLyricsEnabled)) + } + val restored = checkNotNull(PlayerManager.currentSongFlow.value) + assertNull(restored.customName) + assertNull(restored.customArtist) + assertEquals("", restored.originalCoverUrl) + assertEquals(OriginalLyric, restored.originalLyric) + assertEquals(originalTranslation, restored.originalTranslatedLyric) + assertEquals(OriginalRomanization, restored.originalRomanizedLyric) + } + Thread.sleep(25L) + } + val physical = checkNotNull(LocalMediaSupport.inspectEmbeddedLyrics(app, original)) + assertEquals(if (writeLocalMetadata) originalLyrics else editedLyrics, + listOf(physical.lyric, physical.translatedLyric.orEmpty(), physical.romanizedLyric)) + } + } + } catch (error: Throwable) { + failure = error + throw error + } finally { + val failures = mutableListOf() + fun cleanup(action: () -> Unit) { + runCatching(action).exceptionOrNull()?.let(failures::add) + } + cleanup { + if (ownsService) { + instrumentation.runOnMainSync { PlayerManager.pause(forcePersist = true) } + awaitServiceState("清理前停止本次合成音频播放") { + !PlayerManager.isPlayingFlow.value && !PlayerManager.playWhenReadyFlow.value + } + } + } + cleanup { + instrumentation.runOnMainSync { + connection?.let(context::unbindService) + connection = null + if (ownsService) context.stopService(Intent(context, AudioPlayerService::class.java)) + } + } + if (ownsService) cleanup { awaitServiceState("清理本次元数据验收服务") { activeService() == null } } + cleanup { instrumentation.runOnMainSync { PlayerManager.release() } } + cleanup { runBlocking { removePlayingFixtureStatistics(directory, fixtureSongs) } } + cleanup { runBlocking { savedQueue.restore(context) } } + cleanup { runBlocking { + settings.setDynamicIslandLyricsEnabled(savedLyricSettings.dynamicIsland) + settings.setExternalBluetoothLyricsEnabled(savedLyricSettings.externalLyrics) + settings.setExternalBluetoothTranslationEnabled(savedLyricSettings.externalTranslation) + settings.setColorOsLockscreenLyricsEnabled(savedLyricSettings.colorOs) + } } + cleanup { + val coverDirectory = File(context.filesDir, "local_audio_covers").canonicalFile + extractedCovers.filter { it.canonicalFile.parentFile == coverDirectory }.forEach { + check(!it.exists() || it.delete()) + } + fixtureSongs.forEach(LocalMediaSupport::invalidateSongAssetCaches) + check(!directory.exists() || directory.deleteRecursively()) + } + cleanup { instrumentation.runOnMainSync { PlayerManager.initializePreloaded(app, preferences) } } + if (failure != null) failures.forEach(failure::addSuppressed) + else if (failures.isNotEmpty()) { + val first = failures.first() + failures.drop(1).forEach(first::addSuppressed) + throw first + } + } + } + + private fun saveFixtureNamesOnce( + scenario: ActivityScenario, song: SongItem, + title: String, artist: String, writeLocalMetadata: Boolean + ) { + lateinit var owner: NowPlayingSongEditOwner + var saved = 0 + scenario.onActivity { activity -> + owner = NowPlayingSongEditOwner(song, song.customCoverUrl ?: song.coverUrl.orEmpty(), + resolveEditSongBaselineFromSong(song, null, null, null, null), activity.lifecycleScope, {}, + PlayerManagerNowPlayingSongEditPlaybackPort) + owner.updateTitle(title) + owner.updateArtist(artist) + owner.saveEditedSongInfo(song, ViewModelProvider(activity)[NowPlayingViewModel::class.java], + writeLocalMetadata, SnackbarHostState(), activity.resources, onSaved = { saved++ }) + } + try { + awaitServiceState("仅改歌名和歌手的一次保存必须成功") { saved == 1 } + } finally { + instrumentation.runOnMainSync { owner.dispose() } + } + assertEquals(1, saved) + } + + private fun restorePlayingFixtureOnce( + scenario: ActivityScenario, song: SongItem, writeLocalMetadata: Boolean + ) { + lateinit var owner: NowPlayingSongEditOwner + var saved = 0 + scenario.onActivity { activity -> + owner = NowPlayingSongEditOwner( + initialSong = song, initialCoverUrl = song.customCoverUrl ?: song.coverUrl.orEmpty(), + initialBaseline = resolveEditSongBaselineFromSong(song, null, null, null, null), + scope = activity.lifecycleScope, onSavingChanged = {}, + playbackPort = PlayerManagerNowPlayingSongEditPlaybackPort + ) + owner.restoreOriginalInfo(context, song, ViewModelProvider(activity)[NowPlayingViewModel::class.java], + EditSongRestoreSelection(cover = true, title = true, artist = true, lyrics = true)) + } + try { + awaitServiceState("一次恢复选择必须完成") { !owner.isOriginalInfoRestoringState.value } + scenario.onActivity { activity -> + owner.saveEditedSongInfo(song, ViewModelProvider(activity)[NowPlayingViewModel::class.java], + writeLocalMetadata, SnackbarHostState(), activity.resources, onSaved = { saved++ }) + } + awaitServiceState("一次恢复保存必须成功,不能依赖再次点击恢复") { saved == 1 } + } finally { + instrumentation.runOnMainSync { owner.dispose() } + } + assertEquals(1, saved) + } + + private fun playingMetadataMatches( + controller: MediaController, notifications: NotificationManager, songId: Long, + title: String, artist: String, lyrics: List, hasArtwork: Boolean, + notificationLyricsEnabled: Boolean + ): Boolean { + val song = PlayerManager.currentSongFlow.value ?: return false + val metadata = controller.metadata ?: return false + val notification = notifications.activeNotifications.singleOrNull { + it.notification.category == Notification.CATEGORY_TRANSPORT && + BundleCompat.getParcelable(it.notification.extras, Notification.EXTRA_MEDIA_SESSION, + MediaSession.Token::class.java) == controller.sessionToken + }?.notification ?: return false + val sessionText = currentPlayingSessionText(song) ?: return false + if (sessionText.forceLyrics != notificationLyricsEnabled || sessionText.colorOs) return false + if (notificationLyricsEnabled) { + val expectedLine = lyrics.first().substringAfter("]") + val expectedTranslation = lyrics[1].takeIf(String::isNotEmpty)?.substringAfter("]") + val expectedArtist = expectedTranslation ?: "$title - $artist" + if (sessionText.lyric != expectedLine || sessionText.translation.orEmpty() != expectedTranslation.orEmpty() || + sessionText.title != expectedLine || sessionText.artist != expectedArtist + ) return false + if (expectedTranslation != null && + metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION) != "$title - $artist" + ) return false + } else if (sessionText.title != title || sessionText.artist != artist) return false + return song.id == songId && song.displayName() == title && song.displayArtist() == artist && + listOf(song.matchedLyric, song.matchedTranslatedLyric, song.matchedRomanizedLyric) == lyrics && + (hasArtwork || song.coverUrl == null && song.customCoverUrl == null) && + PlayerManager.isPlayingFlow.value && controller.playbackState?.state == PlaybackState.STATE_PLAYING && + metadata.getString(MediaMetadata.METADATA_KEY_TITLE) == sessionText.title && + metadata.getString(MediaMetadata.METADATA_KEY_ARTIST) == sessionText.artist && + metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) == sessionText.displayTitle && + metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE) == sessionText.displaySubtitle && + (metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) != null) == hasArtwork && + (metadata.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON) != null) == hasArtwork && + notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() == title && + notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() == artist && + (notification.getLargeIcon() != null) == hasArtwork + } + + private fun awaitPlayingMetadata( + message: String, controller: MediaController, notifications: NotificationManager, songId: Long, + title: String, artist: String, lyrics: List, hasArtwork: Boolean, + notificationLyricsEnabled: Boolean + ) { + try { + awaitServiceState(message) { + playingMetadataMatches(controller, notifications, songId, title, artist, lyrics, + hasArtwork, notificationLyricsEnabled) + } + } catch (failure: AssertionError) { + var details = "" + instrumentation.runOnMainSync { + details = playingMetadataDiagnostics(controller, notifications, songId, title, artist, + lyrics, hasArtwork, notificationLyricsEnabled) + } + throw AssertionError("$message\n$details", failure) + } + } + + private fun playingMetadataDiagnostics( + controller: MediaController, notifications: NotificationManager, songId: Long, + title: String, artist: String, lyrics: List, hasArtwork: Boolean, + notificationLyricsEnabled: Boolean + ): String = buildString { + appendLine("expected=" + mapOf("songId" to songId, "title" to title, "artist" to artist, + "lyrics" to lyrics, "artwork" to hasArtwork, "notificationLyrics" to notificationLyricsEnabled)) + fun capture(label: String, read: () -> Any?) { + appendLine("$label=" + runCatching(read).getOrElse { "${it.javaClass.simpleName}: ${it.message}" }) + } + fun bitmap(bitmap: Bitmap?): String? = bitmap?.let { "${it.width}x${it.height},recycled=${it.isRecycled}" } + val song = PlayerManager.currentSongFlow.value + if (song?.id != songId) { + appendLine("currentFlow=" + if (song == null) "null" else "unrelated song, content redacted") + return@buildString + } + capture("currentFlow") { mapOf("name" to song.name, "artist" to song.artist, + "displayName" to song.displayName(), "displayArtist" to song.displayArtist(), + "cover" to song.coverUrl, "customCover" to song.customCoverUrl, + "customName" to song.customName, "customArtist" to song.customArtist, + "lyrics" to listOf(song.matchedLyric, song.matchedTranslatedLyric, song.matchedRomanizedLyric), + "originalLyrics" to listOf(song.originalLyric, song.originalTranslatedLyric, song.originalRomanizedLyric), + "lyricSyncEdited" to song.lyricSyncEdited) } + capture("player") { mapOf("isPlaying" to PlayerManager.isPlayingFlow.value, + "playWhenReady" to PlayerManager.playWhenReadyFlow.value, + "controlPlaying" to PlayerManager.playbackControlPlayingFlow.value, + "positionMs" to PlayerManager.playbackPositionFlow.value) } + capture("source") { currentPlayingSessionText(song) } + capture("lyricSwitches") { listOf("dynamicIslandLyricsEnabled", "externalBluetoothLyricsEnabled", + "externalBluetoothTranslationEnabled").associateWith { PlayerManager.field(it) } } + capture("sessionToken") { controller.sessionToken } + capture("sessionPlayback") { controller.playbackState?.let { mapOf("state" to it.state, + "positionMs" to it.position, "speed" to it.playbackSpeed, "error" to it.errorMessage) } } + capture("sessionText") { controller.metadata?.let { metadata -> listOf( + MediaMetadata.METADATA_KEY_TITLE, MediaMetadata.METADATA_KEY_ARTIST, + MediaMetadata.METADATA_KEY_ALBUM, MediaMetadata.METADATA_KEY_ALBUM_ARTIST, + MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, + MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, MediaMetadata.METADATA_KEY_MEDIA_ID, + MediaMetadata.METADATA_KEY_ART_URI, MediaMetadata.METADATA_KEY_ALBUM_ART_URI, + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI + ).associateWith(metadata::getString) } } + capture("sessionBitmaps") { controller.metadata?.let { metadata -> listOf( + MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_ALBUM_ART, + MediaMetadata.METADATA_KEY_DISPLAY_ICON + ).associateWith { bitmap(metadata.getBitmap(it)) } } } + capture("notifications") { notifications.activeNotifications.filter { posted -> + val value = posted.notification + val token = BundleCompat.getParcelable(value.extras, Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java) + val notificationTitle = value.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + value.category == Notification.CATEGORY_TRANSPORT && (token == controller.sessionToken || + notificationTitle in setOf(OriginalTitle, "Edited title 0", "Edited title 1")) + }.map { posted -> + val value = posted.notification + val token = BundleCompat.getParcelable(value.extras, Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java) + mapOf("id" to posted.id, "sessionToken" to token, "sessionMatches" to (token == controller.sessionToken), + "title" to value.extras.getCharSequence(Notification.EXTRA_TITLE), + "text" to value.extras.getCharSequence(Notification.EXTRA_TEXT), + "subText" to value.extras.getCharSequence(Notification.EXTRA_SUB_TEXT), + "largeIcon" to value.getLargeIcon(), "smallIcon" to value.smallIcon) + } } + } + + private data class NotificationLyricSettingsSnapshot( + val dynamicIsland: Boolean, val externalLyrics: Boolean, val externalTranslation: Boolean, val colorOs: Boolean + ) + + private data class PlayingSessionText( + val title: String, val artist: String, val displayTitle: String, val displaySubtitle: String, + val lyric: String?, val translation: String?, val forceLyrics: Boolean, val colorOs: Boolean, + val audioDeviceType: Int?, val bluetoothMode: String + ) + + private fun currentPlayingSessionText(song: SongItem): PlayingSessionText? { + val service = activeService() ?: return null + val owner = checkNotNull((service.field("presentationOwnerDelegate") as Lazy<*>).value) + val source = owner.field("source") + val inputs = source.javaClass.getMethod("metadata").invoke(source) + fun input(name: String): Any? = inputs.javaClass.getMethod(name).invoke(inputs) + val payload = checkNotNull(input("getPayload")) + val forceLyrics = input("getForceSendLyrics") as Boolean + val colorOs = input("getColorOsLyricsEnabled") as Boolean + val audioDeviceType = input("getAudioDeviceType") as? Int + val bluetoothMode = owner.field("bluetoothMode") + val method = Class.forName( + "moe.ouom.neriplayer.core.player.service.presentation.PlaybackServiceMetadataPresentationKt" + ).declaredMethods.single { it.name == "serviceMetadataText" }.apply { isAccessible = true } + val projection = checkNotNull(method.invoke(null, song, payload, audioDeviceType, forceLyrics, + bluetoothMode, input("getAlbum"), colorOs)) + fun text(name: String): String = projection.javaClass.getMethod(name).invoke(projection) as String + fun lyric(name: String): String? = payload.javaClass.getMethod(name).invoke(payload) as? String + return PlayingSessionText(text("getTitle"), text("getArtist"), text("getDisplayTitle"), + text("getDisplaySubtitle"), lyric("getLyric"), lyric("getTranslation"), forceLyrics, colorOs, + audioDeviceType, bluetoothMode.toString()) + } + + private fun assumeNoFixtureSyncTargets() { + val noTargets = runCatching { + val keysets = setOf("__androidx_security_crypto_encrypted_prefs_key_keyset__", + "__androidx_security_crypto_encrypted_prefs_value_keyset__") + val webDavEmpty = context.getSharedPreferences("webdav_secure_prefs", Context.MODE_PRIVATE) + .all.keys.all { it in keysets } + val github = SecureTokenStorage(context) + webDavEmpty && github.isPersistent && github.getToken().isNullOrEmpty() && + github.getRepoOwner().isNullOrEmpty() && github.getRepoName().isNullOrEmpty() + }.getOrDefault(false) + assumeTrue("仅允许没有 GitHub 或 WebDAV 同步目标的专用模拟器", noTargets) + } + + private suspend fun removePlayingFixtureStatistics(directory: File, songs: List) { + if (songs.isEmpty()) return + flushPlaybackStatsPendingWrites(context) + val history = AppContainer.playHistoryRepo + check(history.awaitHistoryLoaded()) + history.removeSongs(songs) + withTimeout(TimeUnit.SECONDS.toMillis(ServiceTimeoutSeconds)) { + history.historyFlow.first { entries -> entries.none { entry -> + songs.any { song -> entry.id == song.id && entry.localFilePath == song.localFilePath } + } } + } + val statistics = AppContainer.playbackStatsRepo + val keys = mutableSetOf() + var cursor: PlaybackStatsCursor? = null + do { + val page = statistics.readPage(PlaybackStatsQuery(), after = cursor) + page.tracks.filter { it.localFilePath?.startsWith("${directory.path}/") == true } + .mapTo(keys) { it.identityKey } + cursor = page.nextCursor + } while (cursor != null) + statistics.removeTracks(keys) + withTimeout(TimeUnit.SECONDS.toMillis(ServiceTimeoutSeconds)) { + while (keys.any { statistics.getStatForTrack(it) != null }) delay(50L) + } + } + private fun activeService(): AudioPlayerService? = AudioPlayerService::class.java .getDeclaredField("activeServiceInstance").apply { isAccessible = true }.get(null) as? AudioPlayerService @@ -563,6 +1076,12 @@ class RestoredLocalPlaybackThreadTest { const val PositionMs = 1_500L const val ServiceTimeoutSeconds = 25L const val ActionBindCar = "moe.ouom.neriplayer.action.BIND_CAR" + const val PlaybackNotificationChannel = "neriplayer_playback_channel" + const val OriginalTitle = "Original title" + const val OriginalArtist = "Original artist" + const val OriginalLyric = "[00:01.00]original lyric" + const val OriginalTranslation = "[00:01.00]original translation" + const val OriginalRomanization = "[00:01.00]original romanization" val QueueFiles = listOf("last_playlist.json", "last_playback_state.json") } } 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 03bc936fd2..610cc8e1d1 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/NeriApp.kt @@ -144,6 +144,7 @@ import moe.ouom.neriplayer.ui.theme.NeriTheme import moe.ouom.neriplayer.ui.theme.biasCoverSeedTowardBlue import moe.ouom.neriplayer.ui.theme.rememberActualSystemDarkTheme import moe.ouom.neriplayer.ui.util.rememberSongDisplayCoverUrl +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.data.model.NeteaseArtistSummary import moe.ouom.neriplayer.data.model.BiliUploaderSummary import moe.ouom.neriplayer.ui.viewmodel.playlist.BiliVideoItem @@ -606,7 +607,7 @@ private fun NeriAppContent( currentSong?.playbackVisualKeyAliases().orEmpty() } val playbackVisualCoverState = rememberPlaybackVisualCoverState( - playbackVisualCoverRequest(displayCoverUrl, currentSongVisualKey) + playbackVisualCoverRequest(displayCoverUrl, currentSongVisualKey, currentSong.hasKnownEmptyLocalCover()) ) val playbackVisualCoverUrl = playbackVisualCoverState.url // 旋转重建时直接复用当前封面颜色,避免首帧回到默认主题 diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/animation/SharedCoverFlight.kt b/app/src/main/java/moe/ouom/neriplayer/ui/animation/SharedCoverFlight.kt index 6dcf8859e7..e6e75fe1c7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/animation/SharedCoverFlight.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/animation/SharedCoverFlight.kt @@ -127,6 +127,12 @@ class SharedCoverFlightState { } } + internal fun clearArtwork() { + sourceBitmap = null + linkedBitmap = null + flightBitmap = null + } + internal fun reportTarget(bounds: Rect, cornerPx: Float) { if (bounds.isEmpty) return targetBounds = bounds @@ -221,7 +227,7 @@ class SharedCoverFlightState { /** 替代封面的位图和两端坐标齐备后才隐藏真实封面 */ val hidesCovers: Boolean - get() = flying || (linked && linkedBitmap != null && sourceBounds != null && + get() = (flying && flightBitmap != null) || (linked && linkedBitmap != null && sourceBounds != null && (hasVisualTarget() || targetCoordinates?.isAttached == true)) fun beginLinked( diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackCompatibility.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackCompatibility.kt index 02e77ed7ee..ebe966800e 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackCompatibility.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/PlaybackCompatibility.kt @@ -26,7 +26,8 @@ fun NeriMiniPlayer( coverIdentityKey: String? = null, visualCoverIdentityKey: String? = null, hasCurrentSong: Boolean = true, - tabletControls: moe.ouom.neriplayer.ui.component.playback.MiniPlayerTabletControls? = null + tabletControls: moe.ouom.neriplayer.ui.component.playback.MiniPlayerTabletControls? = null, + clearCover: Boolean = false ) { moe.ouom.neriplayer.ui.component.playback.NeriMiniPlayer( title = title, @@ -47,7 +48,8 @@ fun NeriMiniPlayer( coverIdentityKey = coverIdentityKey, visualCoverIdentityKey = visualCoverIdentityKey, hasCurrentSong = hasCurrentSong, - tabletControls = tabletControls + tabletControls = tabletControls, + clearCover = clearCover ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgress.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgress.kt new file mode 100644 index 0000000000..d8d6c7a864 --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgress.kt @@ -0,0 +1,134 @@ +package moe.ouom.neriplayer.ui.component.playback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import moe.ouom.neriplayer.R as AppR +import moe.ouom.neriplayer.common.R as CoreCommonR +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import moe.ouom.neriplayer.ui.haptic.HapticTextButton +import moe.ouom.neriplayer.ui.viewmodel.ManualMetadataApplicationState +import moe.ouom.neriplayer.ui.theme.NeriDesign +import moe.ouom.neriplayer.ui.theme.NeriMotion +import kotlin.time.Duration.Companion.milliseconds + +private const val MetadataProgressAnimationMs = 240 +private const val MetadataCompletionHoldMs = 280L + +@Composable +internal fun ManualMetadataApplicationProgress( + state: ManualMetadataApplicationState, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + requestKey: String = "metadata", + onCompleted: () -> Unit = {} +) { + val target = if (state.completed) 1f else + (state.stage?.ordinal ?: 0) / SongMetadataApplyStage.entries.size.toFloat() + val progress = remember(requestKey) { Animatable(target) } + val latestOnCompleted = rememberUpdatedState(onCompleted) + LaunchedEffect(requestKey, state.stage, state.completed) { + progress.animateTo(target, tween(MetadataProgressAnimationMs, easing = NeriMotion.Emphasized)) + if (state.completed) { + val completedAt = withFrameNanos { it } + while (withFrameNanos { it } - completedAt < MetadataCompletionHoldMs.milliseconds.inWholeNanoseconds) { + // 按可见帧保留完成状态,后台等待不提前关闭窗口 + } + latestOnCompleted.value() + } + } + Surface( + modifier.fillMaxWidth().testTag("metadata-application-progress"), + shape = NeriDesign.tokens.cardShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon( + if (state.completed) Icons.Outlined.CheckCircle else Icons.Outlined.MusicNote, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + stringResource(if (state.completed) AppR.string.metadata_apply_completed else metadataApplyStageLabel(state.stage)), + modifier = Modifier.weight(1f).testTag("metadata-application-stage"), + style = MaterialTheme.typography.titleSmall, + minLines = 2, + maxLines = 2 + ) + } + LinearProgressIndicator( + progress = { progress.value }, + modifier = Modifier.fillMaxWidth().height(5.dp).clip(NeriDesign.tokens.chipShape) + .testTag("metadata-stage-progress"), + trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {} + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource( + AppR.string.metadata_apply_stage_count, + if (state.completed) SongMetadataApplyStage.entries.size else (state.stage?.ordinal ?: 0) + 1, + SongMetadataApplyStage.entries.size + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Box(Modifier.height(48.dp), contentAlignment = Alignment.CenterEnd) { + if (state.isRunning) { + HapticTextButton( + onClick = onCancel, + enabled = state.canCancel, + modifier = Modifier.testTag("metadata-application-cancel") + ) { Text(stringResource(CoreCommonR.string.action_cancel)) } + } + } + } + } + } +} + +private fun metadataApplyStageLabel(stage: SongMetadataApplyStage?): Int = when (stage) { + SongMetadataApplyStage.FETCHING_DETAILS, null -> AppR.string.metadata_apply_fetching_details + SongMetadataApplyStage.FETCHING_LYRICS -> AppR.string.metadata_apply_fetching_lyrics + SongMetadataApplyStage.PREPARING_COVER -> AppR.string.metadata_apply_preparing_cover + SongMetadataApplyStage.PREPARING_METADATA -> AppR.string.metadata_apply_preparing_metadata + SongMetadataApplyStage.APPLYING -> AppR.string.metadata_apply_committing +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptions.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptions.kt new file mode 100644 index 0000000000..1ec10585ec --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptions.kt @@ -0,0 +1,305 @@ +package moe.ouom.neriplayer.ui.component.playback + +import androidx.compose.foundation.BorderStroke +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import moe.ouom.neriplayer.R as AppR +import moe.ouom.neriplayer.common.R as CoreCommonR +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import moe.ouom.neriplayer.data.model.settings.lyrics.LyricSourcePreference +import moe.ouom.neriplayer.ui.component.overlay.DensityScaledAlertDialog +import moe.ouom.neriplayer.ui.haptic.HapticTextButton +import moe.ouom.neriplayer.ui.screen.nowplaying.lyrics.stringResId +import moe.ouom.neriplayer.ui.screen.tab.settings.miuix.MiuixSettingsCheckbox +import moe.ouom.neriplayer.ui.screen.tab.settings.miuix.MiuixSettingsSwitch +import moe.ouom.neriplayer.ui.theme.NeriDesign +import moe.ouom.neriplayer.ui.theme.NeriMotion + +internal fun defaultMetadataLyricSources( + preference: LyricSourcePreference, + selectedPlatform: MusicPlatform +): Set = setOf( + preference.matchSource ?: when (selectedPlatform) { + MusicPlatform.CLOUD_MUSIC -> EditableLyricMatchSource.CLOUD_MUSIC + MusicPlatform.QQ_MUSIC -> EditableLyricMatchSource.QQ_MUSIC + } +) + +internal fun canApplyManualMetadataSelection(options: SongMetadataApplyOptions): Boolean = + !(options.originalLyrics || options.translatedLyrics || options.romanizedLyrics) || + options.lyricSources.isNotEmpty() + +@Composable +internal fun ManualMetadataApplyOptionsDialog( + selectedSong: SongSearchInfo, + isLocalSong: Boolean, + initialOptions: SongMetadataApplyOptions, + onDismiss: () -> Unit, + onApply: (SongMetadataApplyOptions) -> Unit +) { + var options by remember(selectedSong.id, selectedSong.source) { + mutableStateOf(initialOptions.copy(writeLocalMetadata = isLocalSong)) + } + val canApply = canApplyManualMetadataSelection(options) + DensityScaledAlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag("metadata-apply-options-dialog"), + title = { Text(stringResource(AppR.string.metadata_apply_options_title)) }, + text = { + Column( + Modifier.heightIn(max = 520.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + selectedSong.songName, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (selectedSong.singer.isNotBlank()) { + Text( + selectedSong.singer, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ManualMetadataApplyOptionsContent(options) { options = it } + if (!canApply) { + Text( + stringResource(CoreCommonR.string.lyrics_match_no_source_selected), + color = MaterialTheme.colorScheme.error + ) + } + } + if (isLocalSong) { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Text( + stringResource(AppR.string.metadata_apply_destination), + style = MaterialTheme.typography.labelLarge + ) + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Min).selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ManualMetadataChoice( + selected = options.writeLocalMetadata, + onClick = { options = options.copy(writeLocalMetadata = true) }, + label = stringResource(AppR.string.metadata_apply_destination_file), + modifier = Modifier.weight(1f).fillMaxHeight().testTag("metadata-apply-write") + ) + ManualMetadataChoice( + selected = !options.writeLocalMetadata, + onClick = { options = options.copy(writeLocalMetadata = false) }, + label = stringResource(AppR.string.metadata_apply_destination_app), + modifier = Modifier.weight(1f).fillMaxHeight().testTag("metadata-apply-app-only") + ) + } + } + } + } + }, + confirmButton = { + HapticTextButton( + enabled = canApply, + onClick = { onApply(options.copy(writeLocalMetadata = isLocalSong && options.writeLocalMetadata)) }, + modifier = Modifier.testTag("metadata-apply-confirm") + ) { + Text(stringResource(AppR.string.metadata_apply_confirm)) + } + }, + dismissButton = { + HapticTextButton( + onClick = onDismiss, + modifier = Modifier.testTag("metadata-apply-cancel") + ) { Text(stringResource(CoreCommonR.string.action_cancel)) } + } + ) +} + +@Composable +private fun ManualMetadataApplyOptionsContent( + options: SongMetadataApplyOptions, + onChange: (SongMetadataApplyOptions) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(NeriDesign.tokens.sectionSpacing)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(AppR.string.metadata_apply_lyric_sources), style = MaterialTheme.typography.labelLarge) + EditableLyricMatchSource.entries.chunked(2).forEach { sources -> + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + sources.forEach { source -> + ManualMetadataChoice( + selected = source in options.lyricSources, + onClick = { + onChange(options.copy(lyricSources = if (source in options.lyricSources) { + options.lyricSources - source + } else options.lyricSources + source)) + }, + label = stringResource(source.stringResId()), + multiSelect = true, + modifier = Modifier.weight(1f).fillMaxHeight().testTag("metadata-source-${source.name}") + ) + } + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(AppR.string.metadata_apply_lyric_content), style = MaterialTheme.typography.labelLarge) + Surface(shape = NeriDesign.tokens.cardShape, color = MaterialTheme.colorScheme.surfaceContainerHighest) { + Column { + ManualMetadataLyricRow( + selected = options.originalLyrics, + onClick = { onChange(options.copy(originalLyrics = !options.originalLyrics)) }, + label = stringResource(CoreCommonR.string.lyrics_original), + modifier = Modifier.testTag("metadata-content-original") + ) + ManualMetadataLyricRow( + selected = options.translatedLyrics, + onClick = { onChange(options.copy(translatedLyrics = !options.translatedLyrics)) }, + label = stringResource(CoreCommonR.string.lyrics_translation), + modifier = Modifier.testTag("metadata-content-translated") + ) + ManualMetadataLyricRow( + selected = options.romanizedLyrics, + onClick = { onChange(options.copy(romanizedLyrics = !options.romanizedLyrics)) }, + label = stringResource(AppR.string.metadata_apply_romanized), + modifier = Modifier.testTag("metadata-content-romanized") + ) + } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp) + .toggleable(options.preferWordTimed, role = Role.Switch) { + onChange(options.copy(preferWordTimed = it)) + }.testTag("metadata-prefer-word-timed"), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(CoreCommonR.string.settings_prefer_word_timed_lyrics), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium + ) + MiuixSettingsSwitch( + checked = options.preferWordTimed, + onCheckedChange = null + ) + } + } + } +} + +@Composable +private fun ManualMetadataChoice( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + multiSelect: Boolean = false +) { + val colors = MaterialTheme.colorScheme + val clean = NeriDesign.isClean + val selectedFill = if (clean) colors.primary else colors.secondaryContainer + val selectedContent = if (clean) colors.onPrimary else colors.onSecondaryContainer + val fill by animateColorAsState( + targetValue = if (selected) selectedFill else colors.surfaceContainerHighest, + animationSpec = NeriMotion.smooth(), + label = "metadata_choice_fill" + ) + val content by animateColorAsState( + targetValue = if (selected) selectedContent else colors.onSurface, + animationSpec = NeriMotion.smooth(), + label = "metadata_choice_content" + ) + val selection = if (multiSelect) { + Modifier.toggleable(selected, role = Role.Checkbox, onValueChange = { onClick() }) + } else { + Modifier.selectable(selected, role = Role.RadioButton, onClick = onClick) + } + Surface( + modifier = modifier.heightIn(min = 48.dp).clip(NeriDesign.tokens.cardShape).then(selection), + shape = NeriDesign.tokens.cardShape, + color = fill, + contentColor = content, + border = if (clean) null else BorderStroke(1.dp, if (selected) colors.primary else colors.outlineVariant) + ) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + label, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun ManualMetadataLyricRow( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier +) { + Row( + modifier.fillMaxWidth().heightIn(min = 48.dp) + .toggleable(selected, role = Role.Checkbox, onValueChange = { onClick() }) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + MiuixSettingsCheckbox(checked = selected, onCheckedChange = null) + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayer.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayer.kt index bd4aa0a578..7e7d1d6794 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayer.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayer.kt @@ -488,7 +488,8 @@ fun NeriMiniPlayer( coverIdentityKey: String? = null, visualCoverIdentityKey: String? = null, hasCurrentSong: Boolean = true, - tabletControls: MiniPlayerTabletControls? = null + tabletControls: MiniPlayerTabletControls? = null, + clearCover: Boolean = false ) { val context = LocalContext.current val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp @@ -507,8 +508,8 @@ fun NeriMiniPlayer( cleanStyle -> NeriDesign.tokens.miniPlayerShape else -> RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) } - val requestedCoverUrl = coverUrl.trimmedNonEmptyOrNull() - val retainedCoverUrl = visualCoverUrl.trimmedNonEmptyOrNull() + val requestedCoverUrl = coverUrl.trimmedNonEmptyOrNull().takeUnless { clearCover } + val retainedCoverUrl = visualCoverUrl.trimmedNonEmptyOrNull().takeUnless { clearCover } val requestedIdentityKey = miniPlayerCoverIdentityKey( identityKey = coverIdentityKey, coverUrl = requestedCoverUrl @@ -547,16 +548,17 @@ fun NeriMiniPlayer( } else { retainedFrame } - var displayedFrame by remember { mutableStateOf(null) } + var displayedFrame by remember(clearCover) { mutableStateOf(null) } var failedCoverFrame by remember(requestedFrame, effectiveRetainedFrame) { mutableStateOf(null) } var clearRetainedFrame by remember(requestedFrame, effectiveRetainedFrame) { mutableStateOf(false) } - val decodedFramesByIdentity = remember { + val decodedFramesByIdentity = remember(clearCover) { mutableStateMapOf() } + val latestClearCover by rememberUpdatedState(clearCover) val latestRequestedFrame by rememberUpdatedState(requestedFrame) val latestRetainedFrame by rememberUpdatedState(effectiveRetainedFrame) val failedFrameForVisibleState = failedCoverFrame?.takeIf { failed -> @@ -640,15 +642,18 @@ fun NeriMiniPlayer( cachedFrame = effectiveCachedRequestedFrame ?: sharedFrameForCurrentIdentity, retainedFrame = cachedRetainedFrame ?: effectiveRetainedFrame, failedFrame = failedFrameForVisibleState, - clearRetainedFrame = clearRetainedFrame + clearRetainedFrame = clearRetainedFrame || clearCover ) + SideEffect { + if (clearCover) sharedCoverFlight?.clearArtwork() + } fun publishDecodedFrame( completedFrame: MiniPlayerCoverFrame, decodedBitmap: ImageBitmap? ) { if ( - decodedBitmap == null || + latestClearCover || decodedBitmap == null || !shouldCommitMiniPlayerCoverFrame( completedFrame = completedFrame, latestRequestedFrame = latestRequestedFrame, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/SongMetadataSearchContent.kt b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/SongMetadataSearchContent.kt index f5cea480ab..d118a46f65 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/SongMetadataSearchContent.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/component/playback/SongMetadataSearchContent.kt @@ -5,6 +5,8 @@ import moe.ouom.neriplayer.ui.i18n.asString import moe.ouom.neriplayer.data.identity.stableKey import android.os.SystemClock +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.background import androidx.compose.foundation.selection.selectableGroup @@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -42,6 +45,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.SideEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,6 +55,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -66,6 +71,9 @@ import moe.ouom.neriplayer.data.model.music.SongSearchInfo import moe.ouom.neriplayer.core.di.AppContainer import moe.ouom.neriplayer.common.logging.NPLogger import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.data.model.settings.lyrics.LyricSourcePreference +import moe.ouom.neriplayer.data.local.media.isLocalSong import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.local.media.displayName import moe.ouom.neriplayer.data.model.stableKey @@ -73,11 +81,14 @@ import moe.ouom.neriplayer.ui.component.sheet.bottomSheetScrollGuard import moe.ouom.neriplayer.ui.haptic.HapticIconButton import moe.ouom.neriplayer.ui.haptic.HapticTextButton import moe.ouom.neriplayer.ui.theme.NeriDesign +import moe.ouom.neriplayer.ui.theme.NeriMotion import moe.ouom.neriplayer.ui.theme.neriRoundedShape import moe.ouom.neriplayer.ui.theme.neriSearchFieldColors import moe.ouom.neriplayer.ui.theme.neriSearchFieldShape import moe.ouom.neriplayer.ui.viewmodel.ManualSearchState +import moe.ouom.neriplayer.ui.viewmodel.ManualMetadataApplicationState import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel +import moe.ouom.neriplayer.ui.screen.playlist.curation.rememberLocalCurationWriteAccessRequester import moe.ouom.neriplayer.util.media.offlineCachedImageRequest import kotlin.time.Duration.Companion.milliseconds import moe.ouom.neriplayer.R as AppR @@ -92,7 +103,9 @@ fun SongMetadataSearchContent( song: SongItem, offlineMode: Boolean, enabled: Boolean, - onSongSelected: (SongSearchInfo) -> Unit, + onSongSelected: (SongSearchInfo, SongMetadataApplyOptions) -> Unit, + onBusyChanged: (Boolean) -> Unit, + onApplied: () -> Unit, onDone: () -> Unit ) { val focusManager = LocalFocusManager.current @@ -102,11 +115,35 @@ fun SongMetadataSearchContent( val searchSessionId = remember(songKey) { mutableStateOf(null) } var isWaitingForPlayback by remember(songKey) { mutableStateOf(true) } val searchState by viewModel.manualSearchState.collectAsStateWithLifecycle() + val application by viewModel.metadataApplicationState.collectAsStateWithLifecycle() + var selectedForApplication by remember(songKey) { mutableStateOf(null) } + var pendingWriteApplication by remember(songKey) { + mutableStateOf?>(null) + } + var writeAccessDenied by remember(songKey) { mutableStateOf(false) } + val preferredLyricSource by AppContainer.settingsRepo.defaultLyricSourceFlow + .collectAsStateWithLifecycle(initialValue = LyricSourcePreference.Automatic) + val preferWordTimed by AppContainer.settingsRepo.preferWordTimedLyricsFlow + .collectAsStateWithLifecycle(initialValue = true) val autoShowKeyboard by AppContainer.settingsRepo.autoShowKeyboardFlow .collectAsStateWithLifecycle(initialValue = false) val pendingMediaLoad = PlayerManager.pendingMediaLoadFlow.collectAsStateWithLifecycle() val playbackState = PlayerManager.playerPlaybackStateFlow.collectAsStateWithLifecycle() val playWhenReady = PlayerManager.playWhenReadyFlow.collectAsStateWithLifecycle() + val requestWriteAccess = rememberLocalCurationWriteAccessRequester( + songs = { if (pendingWriteApplication != null) listOf(song) else emptyList() }, + onGranted = { + pendingWriteApplication?.let { (selected, options) -> onSongSelected(selected, options) } + pendingWriteApplication = null + onBusyChanged(viewModel.metadataApplicationState.value.isRunning) + }, + onDenied = { + pendingWriteApplication = null + writeAccessDenied = true + onBusyChanged(false) + } + ) + SideEffect { onBusyChanged(application.isRunning || pendingWriteApplication != null) } LaunchedEffect(songKey) { searchSessionId.value = viewModel.prepareForSearch(song.displayName()) @@ -137,8 +174,10 @@ fun SongMetadataSearchContent( viewModel.performSearch() } - LaunchedEffect(songKey, autoShowKeyboard, enabled, isWaitingForPlayback) { - if (autoShowKeyboard && enabled && !isWaitingForPlayback) { + val canFocusSearch = enabled && !isWaitingForPlayback && selectedForApplication == null && + pendingWriteApplication == null && !application.isRunning && !application.completed + LaunchedEffect(songKey, autoShowKeyboard, canFocusSearch) { + if (autoShowKeyboard && canFocusSearch) { delay(220.milliseconds) focusRequester.requestFocus() keyboardController?.show() @@ -147,6 +186,7 @@ fun SongMetadataSearchContent( DisposableEffect(songKey) { onDispose { + pendingWriteApplication = null searchSessionId.value?.let(viewModel::finishSearchSession) } } @@ -156,6 +196,8 @@ fun SongMetadataSearchContent( !isWaitingForPlayback && !searchState.isLoading && !searchState.isApplyingMetadata && + !application.completed && + pendingWriteApplication == null && (searchState.selectedPlatform != MusicPlatform.CLOUD_MUSIC || searchState.isCloudMusicAvailable) @@ -167,7 +209,7 @@ fun SongMetadataSearchContent( ) { MetadataSearchInput( state = searchState, - enabled = enabled, + enabled = enabled && pendingWriteApplication == null && !application.completed, canSearch = canSearch, focusRequester = focusRequester, onKeywordChange = viewModel::onKeywordChange, @@ -181,19 +223,36 @@ fun SongMetadataSearchContent( ) MetadataSearchPlatformTabs( state = searchState, - enabled = enabled && !isWaitingForPlayback, + enabled = enabled && !isWaitingForPlayback && pendingWriteApplication == null, onSelectPlatform = viewModel::selectPlatform ) MetadataSearchResults( state = searchState, offlineMode = offlineMode, - enabled = enabled, - isWaitingForPlayback = isWaitingForPlayback, - onSongSelected = onSongSelected + enabled = enabled && pendingWriteApplication == null, + isWaitingForPlayback = isWaitingForPlayback || pendingWriteApplication != null, + application = application, + requestKey = songKey, + onApplied = onApplied, + onCancelApplication = { viewModel.cancelMetadataApplication() }, + onSongSelected = { + focusManager.clearFocus() + keyboardController?.hide() + selectedForApplication = it + } ) + if (application.failed || writeAccessDenied) { + Text( + stringResource(if (writeAccessDenied) { + AppR.string.local_curation_write_access_denied + } else AppR.string.metadata_apply_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } HapticTextButton( onClick = onDone, - enabled = enabled, + enabled = enabled && !application.isRunning && !application.completed && pendingWriteApplication == null, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) @@ -201,6 +260,30 @@ fun SongMetadataSearchContent( Text(stringResource(CoreCommonR.string.action_done)) } } + selectedForApplication?.let { selected -> + ManualMetadataApplyOptionsDialog( + selectedSong = selected, + isLocalSong = song.isLocalSong(), + initialOptions = SongMetadataApplyOptions( + lyricSources = defaultMetadataLyricSources(preferredLyricSource, selected.source), + preferWordTimed = preferWordTimed + ), + onDismiss = { selectedForApplication = null }, + onApply = { options -> + selectedForApplication = null + focusManager.clearFocus() + keyboardController?.hide() + writeAccessDenied = false + if (options.writeLocalMetadata) { + pendingWriteApplication = selected to options + onBusyChanged(true) + requestWriteAccess() + } else { + onSongSelected(selected, options) + } + } + ) + } } @Composable @@ -300,55 +383,82 @@ private fun MetadataSearchPlatformTabs( } @Composable -private fun MetadataSearchResults( +internal fun MetadataSearchResults( state: ManualSearchState, offlineMode: Boolean, enabled: Boolean, isWaitingForPlayback: Boolean, - onSongSelected: (SongSearchInfo) -> Unit + application: ManualMetadataApplicationState, + onCancelApplication: () -> Unit, + onSongSelected: (SongSearchInfo) -> Unit, + requestKey: String = "metadata", + onApplied: () -> Unit = {} ) { val listState = rememberLazyListState() - Box(Modifier.height(300.dp)) { - when { - isWaitingForPlayback || state.isLoading || state.isApplyingMetadata -> { - CircularProgressIndicator(Modifier.align(Alignment.Center)) - } - state.searchResults.isNotEmpty() -> { - LazyColumn( - state = listState, - modifier = Modifier.bottomSheetScrollGuard { - !listState.canScrollBackward - } - ) { - items( - items = state.searchResults, - key = { result -> "${result.source.name}:${result.id}" }, - contentType = { "search_result" } - ) { result -> - MetadataSearchResultItem( - result = result, - offlineMode = offlineMode, - enabled = enabled && !state.isApplyingMetadata, - onClick = { onSongSelected(result) } - ) + val lastApplication = remember(requestKey) { mutableStateOf(application) } + val lastResults = remember(requestKey) { mutableStateOf(state.searchResults) } + val showingApplication = application.isRunning || application.completed + val displayedApplication = if (showingApplication) application else lastApplication.value + val displayedResults = state.searchResults.ifEmpty { lastResults.value } + SideEffect { + if (showingApplication) lastApplication.value = application + if (!state.isLoading && state.searchResults.isNotEmpty()) lastResults.value = state.searchResults + } + val page = when { + showingApplication -> MetadataResultsPage.Application + isWaitingForPlayback || state.isLoading -> MetadataResultsPage.Loading + state.searchResults.isNotEmpty() -> MetadataResultsPage.Results + else -> MetadataResultsPage.Empty + } + Crossfade( + targetState = page, + modifier = Modifier.fillMaxWidth().height(300.dp).testTag("metadata-results-host"), + animationSpec = tween(180, easing = NeriMotion.Emphasized), + label = "metadata search content" + ) { currentPage -> + Box(Modifier.fillMaxSize()) { + when (currentPage) { + MetadataResultsPage.Application -> ManualMetadataApplicationProgress( + displayedApplication, onCancelApplication, + Modifier.align(Alignment.Center).padding(horizontal = 16.dp), + requestKey, onCompleted = { if (showingApplication && application.completed) onApplied() } + ) + MetadataResultsPage.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center)) + MetadataResultsPage.Results -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().testTag("metadata-search-results").bottomSheetScrollGuard { + !listState.canScrollBackward + } + ) { + items( + items = displayedResults, + key = { result -> "${result.source.name}:${result.id}" }, + contentType = { "search_result" } + ) { result -> + MetadataSearchResultItem( + result = result, + offlineMode = offlineMode, + enabled = page == MetadataResultsPage.Results && enabled && !state.isApplyingMetadata, + onClick = { onSongSelected(result) } + ) + } } } - } - else -> { - Text( - text = state.error?.asString() ?: stringResource(CoreCommonR.string.nowplaying_no_search_result), - modifier = Modifier.align(Alignment.Center), - color = if (state.error != null) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } - ) + MetadataResultsPage.Empty -> { + Text( + text = state.error?.asString() ?: stringResource(CoreCommonR.string.nowplaying_no_search_result), + modifier = Modifier.align(Alignment.Center).padding(horizontal = 24.dp), + color = if (state.error != null) MaterialTheme.colorScheme.error else LocalContentColor.current + ) + } } } } } +private enum class MetadataResultsPage { Application, Loading, Results, Empty } + @Composable private fun MetadataSearchResultItem( result: SongSearchInfo, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/navigation/AppNavigationScaffold.kt b/app/src/main/java/moe/ouom/neriplayer/ui/navigation/AppNavigationScaffold.kt index fb9e3641df..f046e6d2b0 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/navigation/AppNavigationScaffold.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/navigation/AppNavigationScaffold.kt @@ -77,6 +77,7 @@ import moe.ouom.neriplayer.ui.effect.glass.LocalAdvancedGlassBackdrops import moe.ouom.neriplayer.ui.effect.glass.captureAdvancedGlassBackdrop import moe.ouom.neriplayer.ui.feedback.AppFeedbackHostEffect import moe.ouom.neriplayer.ui.feedback.NeriSnackbarHost +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.ui.BottomBarLayoutInsets import moe.ouom.neriplayer.ui.banner.MANAGED_LIBRARY_PROCESSING_DRAG_THRESHOLD @@ -541,6 +542,7 @@ private fun AppMiniPlayerOverlay( coverIdentityKey = miniPlayer.songVisualKey, visualCoverIdentityKey = miniPlayer.visualCoverSongKey, hasCurrentSong = miniPlayer.hasSong, + clearCover = miniPlayer.song.hasKnownEmptyLocalCover(), isPlaying = controls.playbackRequested, playPauseEnabled = !controls.usbPreparing, modifier = Modifier.testTag("appMiniPlayer") diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingBackdrop.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingBackdrop.kt index 8726cd3dce..b171c33804 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingBackdrop.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingBackdrop.kt @@ -61,7 +61,8 @@ private fun NowPlayingBlurRetentionEffect( effectOwner: NowPlayingBlurEffectOwner ) { val retentionRequest = NowPlayingBlurRetentionRequest( - blurAvailable, request.background.blurEnabled, request.cover.songKey, request.cover.url + blurAvailable, request.background.blurEnabled, request.cover.songKey, + request.cover.url, request.cover.clearCover ) SideEffect(nowPlayingBlurRetentionAction(effectOwner, retentionRequest)) } @@ -83,21 +84,22 @@ internal fun nowPlayingBackdropFrame( blurState: NowPlayingBlurState, blurAvailable: Boolean ): NowPlayingBackdropFrame { + val effectiveCover = if (cover.clearCover) cover.copy(url = null) else cover val blurStrength = background.blurAmount.coerceIn(0f, 500f) - val effectiveBlurStrength = resolvedNowPlayingBlurStrength(cover.url, blurStrength) - val assetVersion = nowPlayingBlurAssetVersion(cover) + val effectiveBlurStrength = resolvedNowPlayingBlurStrength(effectiveCover.url, blurStrength) + val assetVersion = nowPlayingBlurAssetVersion(effectiveCover) return NowPlayingBackdropFrame( - cover = cover, + cover = effectiveCover, background = background, blurState = blurState, - hasCoverBlur = hasNowPlayingCoverBlur( - blurAvailable, background.blurEnabled, cover.url, blurState.stableCoverUrl + hasCoverBlur = !effectiveCover.clearCover && hasNowPlayingCoverBlur( + blurAvailable, background.blurEnabled, effectiveCover.url, blurState.stableCoverUrl ), blurStrength = effectiveBlurStrength, - imageSizePx = resolvedNowPlayingBlurImageSizePx(cover.url), + imageSizePx = resolvedNowPlayingBlurImageSizePx(effectiveCover.url), assetVersion = assetVersion, requestKey = nowPlayingBlurRequestKey( - cover.url, cover.songKey, effectiveBlurStrength, assetVersion + effectiveCover.url, effectiveCover.songKey, effectiveBlurStrength, assetVersion ), latestRequestKey = null ) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingOverlay.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingOverlay.kt index 958671bedd..f007c8331d 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingOverlay.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppNowPlayingOverlay.kt @@ -43,6 +43,7 @@ import moe.ouom.neriplayer.ui.animation.LocalSharedCoverFlight import moe.ouom.neriplayer.ui.animation.SharedCoverFlightState import moe.ouom.neriplayer.util.media.RetainedPlaybackCoverBitmapCache import moe.ouom.neriplayer.ui.theme.NeriTheme +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.util.platform.PHONE_SMALLEST_SCREEN_WIDTH_DP internal data class NowPlayingOverlayCover( @@ -51,7 +52,9 @@ internal data class NowPlayingOverlayCover( val song: SongItem?, val assetRefreshKey: Int, val assetRefresh: NowPlayingCoverAssetRefresh? = null -) +) { + val clearCover: Boolean get() = song.hasKnownEmptyLocalCover() +} internal data class NowPlayingOverlayTheme( val dynamicColorEnabled: Boolean, @@ -102,9 +105,16 @@ internal fun releaseNowPlayingInputSession( internal fun resolveNowPlayingOverlayArtwork( songKey: String?, - flight: SharedCoverFlightState? -): ImageBitmap? = RetainedPlaybackCoverBitmapCache.getLatestForOwner(songKey)?.bitmap - ?: flight?.miniCoverBitmap + flight: SharedCoverFlightState?, + clearCover: Boolean = false +): ImageBitmap? { + if (clearCover) { + flight?.clearArtwork() + return null + } + return RetainedPlaybackCoverBitmapCache.getLatestForOwner(songKey)?.bitmap + ?: flight?.miniCoverBitmap +} internal fun shouldUseNowPlayingContainerMorph( smallestScreenWidthDp: Int @@ -180,7 +190,7 @@ internal fun AppNowPlayingOverlay( val morph = rememberNowPlayingMorphState( visible = visible, flight = sharedCoverFlight, - artwork = { resolveNowPlayingOverlayArtwork(cover.songKey, sharedCoverFlight) }, + artwork = { resolveNowPlayingOverlayArtwork(cover.songKey, sharedCoverFlight, cover.clearCover) }, backProgress = { if (backPreviewEnabled) { FastOutLinearInEasing.transform(dismissState.progress.coerceIn(0f, 1f)) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppPlaybackVisualOwner.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppPlaybackVisualOwner.kt index bdce2588b4..9d59147c94 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppPlaybackVisualOwner.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/AppPlaybackVisualOwner.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import moe.ouom.neriplayer.data.local.media.displayCoverUrl import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.util.media.CoverArtColorCache import moe.ouom.neriplayer.data.model.artwork.CoverArtColorSample import moe.ouom.neriplayer.util.media.normalizeCoverArtColorCacheKey @@ -34,6 +35,7 @@ import moe.ouom.neriplayer.util.media.isRemoteImageSource import kotlin.time.Duration.Companion.milliseconds internal fun SongItem?.resolveUiCoverSource(context: Context): String? { + if (hasKnownEmptyLocalCover()) return null return this?.displayCoverUrl(context) } @@ -112,22 +114,26 @@ internal data class PlaybackVisualCoverState( internal data class PlaybackVisualCoverRequest( val coverUrl: String?, - val songKey: String? + val songKey: String?, + val clearCover: Boolean = false ) internal fun playbackVisualCoverRequest( coverUrl: String?, - songKey: String? + songKey: String?, + clearCover: Boolean = false ): PlaybackVisualCoverRequest = PlaybackVisualCoverRequest( - coverUrl?.trim()?.takeIf(String::isNotEmpty), songKey + coverUrl?.trim()?.takeIf(String::isNotEmpty)?.takeUnless { clearCover }, songKey, clearCover ) internal fun resolvePlaybackVisualCoverState( currentCoverUrl: String?, previousState: PlaybackVisualCoverState?, currentSongKey: String?, - hasCurrentSong: Boolean + hasCurrentSong: Boolean, + clearCover: Boolean = false ): PlaybackVisualCoverState { + if (clearCover) return PlaybackVisualCoverState(null, currentSongKey) val normalizedCoverUrl = currentCoverUrl?.trim()?.takeIf(String::isNotEmpty) return when { normalizedCoverUrl != null -> PlaybackVisualCoverState( @@ -158,7 +164,8 @@ internal fun rememberPlaybackVisualCoverState( currentSongKey = request.songKey, hasCurrentSong = hasCurrentOrRetainedVisualCover( request.songKey, visualCoverState.value - ) + ), + clearCover = request.clearCover ) SideEffect { synchronizePlaybackVisualCoverState(visualCoverState, resolvedVisualCoverState) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropLayers.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropLayers.kt index 6249626c50..ad93f6cae7 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropLayers.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropLayers.kt @@ -27,11 +27,12 @@ internal data class NowPlayingBackdropFrame( @Composable internal fun NowPlayingBackdropLayers(frame: NowPlayingBackdropFrame) { - val useBlur = shouldUseNowPlayingBlur(frame.hasCoverBlur, frame.blurState.loadFailed) + val useBlur = !frame.cover.clearCover && + shouldUseNowPlayingBlur(frame.hasCoverBlur, frame.blurState.loadFailed) val accentCoverUrl = selectNowPlayingAccentCoverUrl( useBlur, - frame.blurState.stableCoverUrl, - frame.cover.url + frame.blurState.stableCoverUrl.takeUnless { frame.cover.clearCover }, + frame.cover.url.takeUnless { frame.cover.clearCover } ) NowPlayingAccentBackdrop( request = backdropAccentRequest( @@ -130,7 +131,7 @@ private fun NowPlayingBlurDarkenLayer(darken: Float) { @Composable private fun NowPlayingDynamicBackdrop(frame: NowPlayingBackdropFrame) { - if (!frame.background.dynamicEnabled) return + if (!frame.background.dynamicEnabled || frame.cover.clearCover) return HyperBackground( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurEffectOwner.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurEffectOwner.kt index c642481ba4..5bfa506339 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurEffectOwner.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurEffectOwner.kt @@ -58,7 +58,7 @@ internal class NowPlayingBlurEffectOwner( } fun updateRetention(request: NowPlayingBlurRetentionRequest) { - blurState.observeRequest(request.songKey, request.coverUrl) + blurState.observeRequest(request.songKey, request.coverUrl, request.clearCover) if (retentionRequest == request) return retentionRequest = request retentionJob?.cancel() diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurState.kt b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurState.kt index cb76e5a96f..dbaf84d437 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurState.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBlurState.kt @@ -9,6 +9,7 @@ import kotlin.time.Duration.Companion.milliseconds internal class NowPlayingBlurState { private var latestSongKey: String? = null private var latestCoverUrl: String? = null + private var rejectImageResults = false var stableCoverUrl by mutableStateOf(null) private set var stableBlurStrength by mutableStateOf(null) @@ -16,9 +17,11 @@ internal class NowPlayingBlurState { var loadFailed by mutableStateOf(false) private set - fun observeRequest(songKey: String?, coverUrl: String?) { + fun observeRequest(songKey: String?, coverUrl: String?, clearCover: Boolean = false) { latestSongKey = songKey latestCoverUrl = coverUrl + rejectImageResults = clearCover + if (clearCover) clear() } fun clear() { @@ -33,13 +36,14 @@ internal class NowPlayingBlurState { coverUrl: String?, blurStrength: Float ) { - if (latestRequestKey != expectedRequestKey) return + if (rejectImageResults || latestRequestKey != expectedRequestKey) return stableCoverUrl = coverUrl stableBlurStrength = blurStrength loadFailed = false } fun onImageError(latestRequestKey: String?, expectedRequestKey: String?) { + if (rejectImageResults) return loadFailed = resolveNowPlayingBlurLoadFailure( latestRequestKey, expectedRequestKey, @@ -49,7 +53,7 @@ internal class NowPlayingBlurState { } suspend fun reconcileRetention(request: NowPlayingBlurRetentionRequest) { - if (!request.blurAvailable || !request.blurEnabled) { + if (request.clearCover || !request.blurAvailable || !request.blurEnabled) { clear() return } @@ -71,5 +75,6 @@ internal data class NowPlayingBlurRetentionRequest( val blurAvailable: Boolean, val blurEnabled: Boolean, val songKey: String?, - val coverUrl: String? + val coverUrl: String?, + val clearCover: Boolean = false ) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.kt index 60b1802c5a..7b6a1c3f55 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.kt @@ -152,6 +152,7 @@ import moe.ouom.neriplayer.ui.component.lyrics.resolveLyricSeekPosition import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel import moe.ouom.neriplayer.data.model.NeteaseArtistSummary import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.ui.viewmodel.tab.AlbumSummary import moe.ouom.neriplayer.ui.viewmodel.tab.PlaylistSummary import moe.ouom.neriplayer.ui.haptic.HapticFilledIconButton @@ -619,7 +620,7 @@ fun NowPlayingScreen( } else { playbackSongKeyAliases } - val currentCoverUrl = resolveNowPlayingCoverRequestUrl( + val currentCoverUrl = if (currentSong.hasKnownEmptyLocalCover()) null else resolveNowPlayingCoverRequestUrl( resolvedCoverUrl = resolvedCoverUrl, resolvedCoverSongKey = playbackSongKey, resolvedCoverOwnerRequired = playbackSongKey == null && currentSongVisualKey != null, @@ -1210,7 +1211,8 @@ fun NowPlayingScreen( coverCacheKey = coverSource.cacheKey, contentDescription = coverSource.contentDescription, modifier = modifier, - songKeyAliases = coverSource.songKeyAliases + songKeyAliases = coverSource.songKeyAliases, + clearCover = coverSource.clearCover ) } ) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.kt index 240653ad98..8cc9b2ef2a 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.kt @@ -69,6 +69,7 @@ import moe.ouom.neriplayer.platform.bilibili.api.client.BiliClient import moe.ouom.neriplayer.data.model.music.SongSearchInfo import moe.ouom.neriplayer.data.model.youtube.music.YouTubeMusicCreatorSummary import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions import moe.ouom.neriplayer.data.model.playback.PlaybackAudioInfo import moe.ouom.neriplayer.core.player.playback.BiliVideoSkipPlaybackController import moe.ouom.neriplayer.data.local.media.isLocalSong @@ -380,27 +381,29 @@ internal class MoreOptionsSheetOwner( private set var isEditSongSaving by mutableStateOf(false) private set + var isMetadataApplicationBusy by mutableStateOf(false) + private set var sheetImeVisible by mutableStateOf(false) private set val sheetGesturesEnabled: Boolean - get() = page != MoreOptionsPage.LISTEN_TOGETHER && !isEditSongSaving + get() = page != MoreOptionsPage.LISTEN_TOGETHER && !isEditSongSaving && !isMetadataApplicationBusy val onBack: () -> Unit = ::back val onDismissRequest: () -> Unit = ::dismiss val onSavingChanged: (Boolean) -> Unit = ::setEditSaving fun open(target: MoreOptionsPage) { - if (!isEditSongSaving) page = target + if (!isEditSongSaving && !isMetadataApplicationBusy) page = target } /** 返回会回到面板主页;会直接关闭面板或正在保存时为 false,交给弹层自身或保存期间的拦截处理 */ val canGoBackToMainPage: Boolean - get() = !isEditSongSaving && page != MoreOptionsPage.MAIN && + get() = !isEditSongSaving && !isMetadataApplicationBusy && page != MoreOptionsPage.MAIN && !(page == initialPage && initialPage != MoreOptionsPage.MAIN) fun back() { - if (isEditSongSaving) return + if (isEditSongSaving || isMetadataApplicationBusy) return if (page == initialPage && initialPage != MoreOptionsPage.MAIN) dismiss() else page = MoreOptionsPage.MAIN } @@ -409,9 +412,13 @@ internal class MoreOptionsSheetOwner( isEditSongSaving = saving } + fun setMetadataApplying(applying: Boolean) { + isMetadataApplicationBusy = applying + } + /** 子页转场、保存和关闭过程各自持有返回,其余状态由面板接管关闭手势 */ val canPredictiveDismiss: Boolean - get() = !isDismissing && !isEditSongSaving && !canGoBackToMainPage + get() = !isDismissing && !isEditSongSaving && !isMetadataApplicationBusy && !canGoBackToMainPage fun updateSheetImeVisibility(visible: Boolean) { sheetImeVisible = visible @@ -427,7 +434,7 @@ internal class MoreOptionsSheetOwner( fun dismiss() = dismiss({}) fun dismiss(afterHidden: () -> Unit) { - if (isDismissing || isEditSongSaving) return + if (isDismissing || isEditSongSaving || isMetadataApplicationBusy) return isDismissing = true scope.launch { try { @@ -449,9 +456,10 @@ private class MoreOptionsSearchActions( private val song: SongItem, private val owner: MoreOptionsSheetOwner ) { - val onSongSelected: (SongSearchInfo) -> Unit = { result -> - owner.dismiss { viewModel.onSongSelected(song, result) } + val onSongSelected: (SongSearchInfo, SongMetadataApplyOptions) -> Unit = { result, options -> + if (viewModel.onSongSelected(song, result, options = options)) owner.setMetadataApplying(true) } + val onApplied: () -> Unit = { owner.dismiss() } val onDone: () -> Unit = { owner.open(MoreOptionsPage.MAIN) } } @@ -748,11 +756,11 @@ internal fun shouldHideCompactEditSongHandle(compactEditSheet: Boolean, imeVisib /** * 子页返回主页由 [MoreOptionsAnimatedPage] 的可拖动转场处理,关闭面板由独立的预测性返回处理器接管; - * 这里只在保存歌曲信息期间吞掉返回,避免面板被收起 + * 保存与应用歌曲信息期间吞掉返回,避免面板被收起 */ @Composable private fun MoreOptionsBackHandlers(owner: MoreOptionsSheetOwner) { - BackHandler(enabled = owner.isEditSongSaving) {} + BackHandler(enabled = owner.isEditSongSaving || owner.isMetadataApplicationBusy) {} } @Composable @@ -953,12 +961,17 @@ private fun MoreOptionsSearchContent( owner: MoreOptionsSheetOwner ) { val actions = MoreOptionsSearchActions(viewModel, actualSong, owner) + DisposableEffect(owner) { + onDispose { owner.setMetadataApplying(false) } + } SongMetadataSearchContent( viewModel = viewModel, song = actualSong, offlineMode = offlineMode, enabled = !owner.isDismissing, onSongSelected = actions.onSongSelected, + onBusyChanged = owner::setMetadataApplying, + onApplied = actions.onApplied, onDone = actions.onDone ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/cover/NowPlayingCover.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/cover/NowPlayingCover.kt index aeb0dc59c1..4cbdae3ab1 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/cover/NowPlayingCover.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/cover/NowPlayingCover.kt @@ -87,6 +87,7 @@ import moe.ouom.neriplayer.ui.animation.sharedCoverTarget import moe.ouom.neriplayer.ui.theme.ContinuousRoundedCornerShape import moe.ouom.neriplayer.ui.theme.NeriDesign import moe.ouom.neriplayer.util.media.copyBitmapForRetainedDisplay +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.util.media.offlineCachedImageRequest import kotlin.math.min import kotlin.math.roundToInt @@ -513,11 +514,20 @@ internal class NowPlayingCoverOwner( fun requestFor(input: NowPlayingCoverRequestInput): NowPlayingCoverRequest? { if (input != composedRequestInput) { composedRequestInput = input - composedRequest = newRequest(input.coverUrl, input.songKey, input.cacheKey) + composedRequest = newRequest(input.coverUrl.takeUnless { input.clearCover }, input.songKey, input.cacheKey) + if (input.clearCover) clearRestoredArtwork(input.songKey) } return composedRequest } + private fun clearRestoredArtwork(songKey: String?) { + displayedFrame = null + failedCoverRequest = null + decodedFramesBySongKey.entries.removeAll { (_, frame) -> frame.ownerSongKey == songKey } + clearedInput = NowPlayingCoverInputKey(songKey, null) + closePreview() + } + fun newRequest(coverUrl: String?, songKey: String?, coverCacheKey: String?): NowPlayingCoverRequest? { cancelGraceClear() val request = buildNowPlayingCoverRequest(coverUrl, songKey, coverCacheKey, Any()) @@ -776,7 +786,8 @@ internal data class NowPlayingCoverSource( val songKey: String?, val songKeyAliases: List, val cacheKey: String, - val contentDescription: String + val contentDescription: String, + val clearCover: Boolean = false ) internal fun buildNowPlayingCoverSource( @@ -787,15 +798,19 @@ internal fun buildNowPlayingCoverSource( assetRootGeneration: Long, assetSongRevision: Long, song: SongItem? -): NowPlayingCoverSource = NowPlayingCoverSource( - coverUrl = coverUrl, - songKey = songKey, - songKeyAliases = songKeyAliases, - cacheKey = buildNowPlayingCoverCacheKey( - coverUrl, downloadPresenceVersion, assetRootGeneration, assetSongRevision - ), - contentDescription = song?.displayName().orEmpty() -) +): NowPlayingCoverSource { + val clearCover = song.hasKnownEmptyLocalCover() + return NowPlayingCoverSource( + coverUrl = coverUrl.takeUnless { clearCover }, + songKey = songKey, + songKeyAliases = songKeyAliases, + cacheKey = buildNowPlayingCoverCacheKey( + coverUrl, downloadPresenceVersion, assetRootGeneration, assetSongRevision + ), + contentDescription = song?.displayName().orEmpty(), + clearCover = clearCover + ) +} internal fun Modifier.nowPlayingCoverPanelModifier( wideLandscape: Boolean, @@ -1017,7 +1032,8 @@ private fun NowPlayingCoverImageFrame( offlineMode = offlineMode, coverCacheKey = source.cacheKey, contentDescription = source.contentDescription, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + clearCover = source.clearCover ) } } @@ -1209,10 +1225,11 @@ internal fun StableNowPlayingCoverImage( coverCacheKey: String?, contentDescription: String?, modifier: Modifier, - songKeyAliases: List + songKeyAliases: List, + clearCover: Boolean = false ) { val presentation = rememberNowPlayingCoverPresentation( - owner, coverUrl, songKey, coverCacheKey, songKeyAliases + owner, coverUrl, songKey, coverCacheKey, songKeyAliases, clearCover ) NowPlayingCoverSurface( owner, presentation, context, coverRequestSizePx, offlineMode, @@ -1226,9 +1243,10 @@ private fun rememberNowPlayingCoverPresentation( coverUrl: String?, songKey: String?, coverCacheKey: String?, - songKeyAliases: List + songKeyAliases: List, + clearCover: Boolean ): NowPlayingCoverPresentation { - val request = owner.requestFor(NowPlayingCoverRequestInput(coverUrl, songKey, coverCacheKey)) + val request = owner.requestFor(NowPlayingCoverRequestInput(coverUrl, songKey, coverCacheKey, clearCover)) // 封面页重建时只从当前歌曲恢复,留在页面内切歌时才保留交接帧 var surfaceFrame by remember(owner) { mutableStateOf(null) } val presentation = owner.presentation(request, songKey, songKeyAliases, fallbackFrame = surfaceFrame) @@ -1246,7 +1264,7 @@ internal fun PassiveNowPlayingCoverImage( modifier: Modifier ) { val request = owner.requestFor( - NowPlayingCoverRequestInput(source.coverUrl, source.songKey, source.cacheKey) + NowPlayingCoverRequestInput(source.coverUrl, source.songKey, source.cacheKey, source.clearCover) ) var surfaceFrame by remember(owner) { mutableStateOf(null) } val presentation = owner.presentation( @@ -1276,7 +1294,8 @@ internal fun PassiveNowPlayingCoverImage( internal data class NowPlayingCoverRequestInput( val coverUrl: String?, val songKey: String?, - val cacheKey: String? + val cacheKey: String?, + val clearCover: Boolean = false ) @Composable diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditOwner.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditOwner.kt index ef2d9ca718..7e6150fc80 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditOwner.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditOwner.kt @@ -220,8 +220,7 @@ internal class NowPlayingSongEditOwner( } fun syncUneditedSong(song: SongItem, resolvedCoverUrl: String?) { - isOriginalInfoRestoring = false - if (userHasEdited) return + if (userHasEdited || savingInProgress || isOriginalInfoRestoring) return coverUrl = resolveEditSongInitialCoverUrl(song, resolvedCoverUrl) coverWasManuallyChanged = false songName = song.customName ?: song.name @@ -247,6 +246,7 @@ internal class NowPlayingSongEditOwner( } fun applyResolvedCover(song: SongItem, resolvedCoverUrl: String?) { + if (song.hasKnownEmptyEditSongCover()) return if (!userHasEdited) refreshBaselineCover(song, resolvedCoverUrl) if (shouldApplyResolvedEditSongCover(userHasEdited, coverUrl, resolvedCoverUrl)) { coverUrl = resolvedCoverUrl.orEmpty() @@ -254,6 +254,7 @@ internal class NowPlayingSongEditOwner( } private fun refreshBaselineCover(song: SongItem, resolvedCoverUrl: String?) { + if (song.originalCoverUrl == "" || song.customCoverUrl != null) return val refreshed = refreshedEditSongBaselineCover( editBaseline.coverUrl, resolveEditSongInitialCoverUrl(song, resolvedCoverUrl) @@ -427,7 +428,7 @@ internal class NowPlayingSongEditOwner( saveInAppOnly: () -> Unit ) { if (!canStartEditAction()) return - dispatchSaveDestination(song, onConfirmationNeeded, saveInAppOnly) + dispatchSaveDestination(latestSong(song), onConfirmationNeeded, saveInAppOnly) } private fun dispatchSaveDestination( @@ -435,14 +436,7 @@ internal class NowPlayingSongEditOwner( onConfirmationNeeded: () -> Unit, saveInAppOnly: () -> Unit ) { - if (shouldConfirmLocalMetadataWriteBack( - song = song, - title = songName, - artist = artistName, - coverUrl = coverUrl, - hasPendingLyricsChange = hasPendingLyricsChange() - ) - ) { + if (shouldConfirmLocalMetadataWriteBack(song)) { onConfirmationNeeded() showLocalMetadataWriteBackConfirmState.value = true } else { @@ -450,8 +444,6 @@ internal class NowPlayingSongEditOwner( } } - private fun hasPendingLyricsChange(): Boolean = shouldClearLyrics || shouldRestoreLyrics - fun saveEditedSongInfo( song: SongItem, viewModel: NowPlayingViewModel, @@ -609,7 +601,7 @@ internal class NowPlayingSongEditOwner( restoreBaseArtist = shouldRestoreArtistBase, clearMatchedMetadata = shouldClearMatchedMetadata, writeLocalMetadata = writeLocalMetadata, - writeLyrics = plan.writeLyricsWithSongMetadata, + writeLyrics = shouldWriteEditSongLyrics(song, plan, writeLocalMetadata), persistManualRemoteCover = shouldPersistManualCover(), restoreBaseLyrics = shouldRestoreLyrics ) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt index 21cb5ff179..85dc92984d 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt @@ -15,11 +15,10 @@ import moe.ouom.neriplayer.data.local.media.isLocalSong import moe.ouom.neriplayer.data.local.media.isMediaStoreCoverReference import moe.ouom.neriplayer.data.identity.isSyncableRemoteSong import moe.ouom.neriplayer.data.model.SongItem -import moe.ouom.neriplayer.data.local.media.displayArtist import moe.ouom.neriplayer.data.local.media.displayCoverUrl -import moe.ouom.neriplayer.data.local.media.displayName import moe.ouom.neriplayer.data.model.lyrics.LyricsEditorSeed import moe.ouom.neriplayer.data.model.lyrics.LyricsEditorSource +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel internal fun shouldFetchOriginalSongInfo(song: SongItem): Boolean { @@ -52,12 +51,18 @@ internal fun latestMatchingEditSong(current: SongItem?, original: SongItem): Son internal fun resolveEditSongInitialCoverUrl( song: SongItem, resolvedDisplayCoverUrl: String? -): String = selectEditSongInitialCover( - directCover = song.displayCoverUrl(), - downloadedCover = AudioDownloadManager.peekLocalCoverUri(song), - originalCover = song.originalCoverUrl, - resolvedDisplayCoverUrl = resolvedDisplayCoverUrl -) +): String { + if (song.hasKnownEmptyEditSongCover()) return "" + return selectEditSongInitialCover( + directCover = song.displayCoverUrl(), + downloadedCover = AudioDownloadManager.peekLocalCoverUri(song), + originalCover = song.originalCoverUrl, + resolvedDisplayCoverUrl = resolvedDisplayCoverUrl + ) +} + +internal fun SongItem.hasKnownEmptyEditSongCover(): Boolean = + hasKnownEmptyLocalCover() internal fun selectEditSongInitialCover( directCover: String?, @@ -265,9 +270,12 @@ private fun managedEditSongCover( resolved: String?, baseline: ManagedDownloadRestorableMetadata.Baseline?, current: String -): String = listOfNotNull(resolved, baseline?.coverReference, current) - .firstOrNull { !isMediaStoreCoverReference(it) } - .orEmpty() +): String { + if (baseline?.coverReference == "") return "" + return listOfNotNull(resolved, baseline?.coverReference, current) + .firstOrNull { !isMediaStoreCoverReference(it) } + .orEmpty() +} private fun managedOriginalLyric( baseline: ManagedDownloadRestorableMetadata.Baseline?, @@ -324,6 +332,23 @@ internal data class EditSongLyricsSavePlan( val persistLocalSidecars: Boolean ) +internal fun shouldWriteEditSongLyrics( + song: SongItem, + plan: EditSongLyricsSavePlan, + writeLocalMetadata: Boolean +): Boolean { + if (plan.writeLyricsWithSongMetadata) return true + if (!writeLocalMetadata || !song.isLocalSong() || plan.draft != null) return false + return listOf( + song.matchedLyric, + song.matchedTranslatedLyric, + song.matchedRomanizedLyric, + song.originalLyric, + song.originalTranslatedLyric, + song.originalRomanizedLyric + ).any { it != null } +} + internal fun resolveEditSongLyricsSavePlan( pendingDraft: EditSongLyricsDraft?, shouldClearLyrics: Boolean, @@ -499,28 +524,7 @@ private fun NowPlayingViewModel.OriginalSongInfo.hasLyrics(): Boolean = private fun preferredRestoreLyric(source: String?, baseline: String?): String = source?.takeIf(String::isNotBlank) ?: baseline.orEmpty() -internal fun shouldConfirmLocalMetadataWriteBack( - song: SongItem, - title: String, - artist: String, - coverUrl: String, - hasPendingLyricsChange: Boolean = false -): Boolean { - if (!song.isLocalSong()) return false - if (hasPendingLyricsChange) return true - return editSongTitleChanged(song, title) || - editSongArtistChanged(song, artist) || - editSongCoverChanged(song, coverUrl) -} - -private fun editSongTitleChanged(song: SongItem, title: String): Boolean = - song.displayName().trim() != title.trim().ifBlank { song.name } - -private fun editSongArtistChanged(song: SongItem, artist: String): Boolean = - song.displayArtist().trim() != artist.trim().ifBlank { song.artist } - -private fun editSongCoverChanged(song: SongItem, coverUrl: String): Boolean = - (song.customCoverUrl ?: song.coverUrl)?.trim() != coverUrl.trim().ifBlank { null } +internal fun shouldConfirmLocalMetadataWriteBack(song: SongItem): Boolean = song.isLocalSong() internal fun shouldAllowLocalCoverReplacement( song: SongItem, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditSheet.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditSheet.kt index 97247bcb36..15ba31fdc5 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditSheet.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditSheet.kt @@ -220,7 +220,7 @@ fun EditSongInfoSheet( val scrollState = rememberScrollState() - LaunchedEffect(actualSong.stableKey()) { + LaunchedEffect(actualSong) { owner.syncUneditedSong(actualSong, resolvedDisplayCoverUrl) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/util/CoverUrlState.kt b/app/src/main/java/moe/ouom/neriplayer/ui/util/CoverUrlState.kt index 34095c9cd3..3b3bbea357 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/util/CoverUrlState.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/util/CoverUrlState.kt @@ -131,6 +131,9 @@ internal fun choosePreferredPlaylistCover( private fun String?.normalizedCoverReference(): String? = this?.trim()?.takeIf(String::isNotBlank) +internal fun SongItem?.hasKnownEmptyLocalCover(): Boolean = this != null && + isLocalSong() && originalCoverUrl == "" && listOf(coverUrl, customCoverUrl).all { it.isNullOrBlank() } + internal fun shouldProbeFastLocalCoverCandidate( isLocalSong: Boolean, immediateCover: String?, @@ -247,6 +250,7 @@ internal fun rememberSongDisplayCoverUrl( allowEmbeddedCoverFallback: Boolean = true, localAssetGeneration: String? = null ): String? { + if (song.hasKnownEmptyLocalCover()) return null val context = LocalContext.current val appContext = remember(context) { context.applicationContext } val localCoverReference = remember(song, downloadPresenceVersion, localAssetGeneration) { diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwner.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwner.kt new file mode 100644 index 0000000000..e52e516bcd --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwner.kt @@ -0,0 +1,68 @@ +package moe.ouom.neriplayer.ui.viewmodel + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage + +data class ManualMetadataApplicationState( + val isRunning: Boolean = false, + val stage: SongMetadataApplyStage? = null, + val completed: Boolean = false, + val failed: Boolean = false +) { + val canCancel: Boolean get() = isRunning && stage != SongMetadataApplyStage.APPLYING +} + +internal class ManualMetadataApplicationOwner( + private val onStateChanged: (ManualMetadataApplicationState) -> Unit = {} +) { + private val mutableState = MutableStateFlow(ManualMetadataApplicationState()) + val state = mutableState.asStateFlow() + private var generation = 0L + private var job: Job? = null + + fun start( + onComplete: (Boolean) -> Unit = {}, + launch: ((SongMetadataApplyStage) -> Unit, (Boolean) -> Unit) -> Job? + ): Boolean { + if (mutableState.value.isRunning) return false + val request = ++generation + publish(ManualMetadataApplicationState(isRunning = true, stage = SongMetadataApplyStage.FETCHING_DETAILS)) + val launched = launch( + { stage -> + if (request == generation && mutableState.value.isRunning) { + publish(mutableState.value.copy(stage = stage)) + } + }, + { success -> + if (request == generation && mutableState.value.isRunning) { + job = null + publish(mutableState.value.copy(isRunning = false, completed = success, failed = !success)) + onComplete(success) + } + } + ) + if (request == generation && mutableState.value.isRunning) job = launched + return true + } + + fun cancel(): Boolean { + if (!mutableState.value.canCancel) return false + ++generation + val cancelled = job + job = null + publish(ManualMetadataApplicationState()) + cancelled?.cancel() + return true + } + + fun reset() { + if (!mutableState.value.isRunning) publish(ManualMetadataApplicationState()) + } + + private fun publish(value: ManualMetadataApplicationState) { + mutableState.value = value + onStateChanged(value) + } +} diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/NowPlayingViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/NowPlayingViewModel.kt index 8d0d8cbe36..0e7aa19763 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/NowPlayingViewModel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/NowPlayingViewModel.kt @@ -41,6 +41,7 @@ import moe.ouom.neriplayer.data.model.music.MusicPlatform import moe.ouom.neriplayer.data.model.music.SongSearchInfo import moe.ouom.neriplayer.data.model.youtube.music.YouTubeMusicCreatorSummary import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.encyclopedia.encyclopediaSongKey @@ -125,6 +126,10 @@ class NowPlayingViewModel : ViewModel() { ) ) val manualSearchState = _manualSearchState.asStateFlow() + private val metadataApplicationOwner = ManualMetadataApplicationOwner { application -> + _manualSearchState.update { it.copy(isApplyingMetadata = application.isRunning) } + } + val metadataApplicationState = metadataApplicationOwner.state private val _encyclopediaState = MutableStateFlow(SongEncyclopediaUiState.Idle) @@ -210,6 +215,7 @@ class NowPlayingViewModel : ViewModel() { } fun prepareForSearch(initialKeyword: String): Long { + metadataApplicationOwner.reset() cancelSearchRequest() val sessionId = ++nextSearchSessionId activeSearchSessionId = sessionId @@ -325,6 +331,8 @@ class NowPlayingViewModel : ViewModel() { if (activeSearchSessionId != sessionId) return activeSearchSessionId = 0L cancelSearchRequest() + metadataApplicationOwner.cancel() + metadataApplicationOwner.reset() } private fun cancelSearchRequest() { @@ -339,21 +347,22 @@ class NowPlayingViewModel : ViewModel() { fun onSongSelected( originalSong: SongItem, selectedSong: SongSearchInfo, - onComplete: (Boolean) -> Unit = {} + onComplete: (Boolean) -> Unit = {}, + options: SongMetadataApplyOptions? = null ): Boolean { - if (_manualSearchState.value.isApplyingMetadata) return false - _manualSearchState.update { it.copy(isApplyingMetadata = true) } - PlayerManager.replaceMetadataFromSearch( - originalSong = originalSong, - selectedSong = selectedSong, - onComplete = { success -> - _manualSearchState.update { it.copy(isApplyingMetadata = false) } - onComplete(success) - } - ) - return true + return metadataApplicationOwner.start(onComplete) { onProgress, finished -> + PlayerManager.replaceMetadataFromSearch( + originalSong = originalSong, + selectedSong = selectedSong, + onComplete = finished, + options = options, + onProgress = onProgress + ) + } } + fun cancelMetadataApplication() = metadataApplicationOwner.cancel() + fun downloadSong(context: Context, song: SongItem) { GlobalDownloadManager.startDownload(context, song) } diff --git a/app/src/main/res/values-en/strings_metadata_application.xml b/app/src/main/res/values-en/strings_metadata_application.xml new file mode 100644 index 0000000000..8fe6340ecb --- /dev/null +++ b/app/src/main/res/values-en/strings_metadata_application.xml @@ -0,0 +1,19 @@ + + + Get Song Info + Lyric sources + Lyric content + Romanization + Save to + Audio file + App only + Get and Apply + Getting song info + Getting lyrics + Getting artwork + Preparing to save + Saving song info + Song info saved + %1$d / %2$d + Could not apply song info. Check storage access or the network and retry. + diff --git a/app/src/main/res/values-zh/strings_metadata_application.xml b/app/src/main/res/values-zh/strings_metadata_application.xml new file mode 100644 index 0000000000..70b1d2c820 --- /dev/null +++ b/app/src/main/res/values-zh/strings_metadata_application.xml @@ -0,0 +1,19 @@ + + + 获取歌曲信息 + 歌词来源 + 歌词内容 + 音译 + 保存到 + 音频文件 + 仅应用内 + 获取并应用 + 正在获取歌曲信息 + 正在获取歌词 + 正在获取封面 + 正在准备保存 + 正在保存歌曲信息 + 歌曲信息已保存 + %1$d / %2$d + 应用失败,请检查授权或网络后重试 + diff --git a/app/src/main/res/values/strings_metadata_application.xml b/app/src/main/res/values/strings_metadata_application.xml new file mode 100644 index 0000000000..70b1d2c820 --- /dev/null +++ b/app/src/main/res/values/strings_metadata_application.xml @@ -0,0 +1,19 @@ + + + 获取歌曲信息 + 歌词来源 + 歌词内容 + 音译 + 保存到 + 音频文件 + 仅应用内 + 获取并应用 + 正在获取歌曲信息 + 正在获取歌词 + 正在获取封面 + 正在准备保存 + 正在保存歌曲信息 + 歌曲信息已保存 + %1$d / %2$d + 应用失败,请检查授权或网络后重试 + diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/AppNowPlayingOverlayArtworkTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/AppNowPlayingOverlayArtworkTest.kt index 6b8c386855..12bc535fef 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/AppNowPlayingOverlayArtworkTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/AppNowPlayingOverlayArtworkTest.kt @@ -3,11 +3,15 @@ package moe.ouom.neriplayer.ui import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ImageBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest import moe.ouom.neriplayer.ui.animation.SharedCoverFlightState import moe.ouom.neriplayer.ui.playback.visual.resolveNowPlayingOverlayArtwork import moe.ouom.neriplayer.util.media.RetainedPlaybackCoverBitmapCache +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -46,4 +50,78 @@ class AppNowPlayingOverlayArtworkTest { assertNull(resolveNowPlayingOverlayArtwork("overlay-artwork-empty", SharedCoverFlightState())) assertNull(resolveNowPlayingOverlayArtwork(null, null)) } + + @Test + fun explicitlyClearedArtworkDoesNotReuseSharedCacheOrMiniHandoff() { + val songKey = "overlay-artwork-restored-empty" + val retained = ImageBitmap(2, 2) + val mini = ImageBitmap(2, 2) + RetainedPlaybackCoverBitmapCache.put(songKey, "content://overlay/edited", null, retained) + val flight = SharedCoverFlightState().apply { + reportSource(Rect(0f, 0f, 48f, 48f), 8f, mini) + } + + assertSame(retained, resolveNowPlayingOverlayArtwork(songKey, flight)) + assertNull(resolveNowPlayingOverlayArtwork(songKey, flight, clearCover = true)) + assertNull(resolveNowPlayingOverlayArtwork("overlay-artwork-not-cached", flight, clearCover = true)) + flight.beginLinked(resolveNowPlayingOverlayArtwork(songKey, flight, clearCover = true)) + assertNull("the real morph must not fall back to the stale mini source", flight.linkedBitmap) + } + + @Test + fun clearingArtworkDuringALinkedMorphRemovesItsStartedBitmapAndPreservesGeometry() { + val bitmap = ImageBitmap(2, 2) + val sourceBounds = Rect(12f, 720f, 60f, 768f) + val containerBounds = Rect(0f, 700f, 400f, 780f) + val flight = SharedCoverFlightState().apply { + reportSource(sourceBounds, 8f, bitmap) + reportMiniPlayerContainer(containerBounds) + reportTarget(Rect(20f, 100f, 380f, 460f), 24f) + beginLinked(bitmap) + } + assertTrue(flight.linked) + assertSame(bitmap, flight.linkedBitmap) + val frameBeforeClear = flight.currentFrame() + + assertNull(resolveNowPlayingOverlayArtwork("overlay-cleared-active-linked", flight, clearCover = true)) + + assertEquals(sourceBounds, flight.miniCoverBounds) + assertEquals(containerBounds, flight.miniPlayerContainerBounds) + assertEquals(frameBeforeClear, flight.currentFrame()) + assertTrue("clearing artwork must preserve the linked morph owner", flight.linked) + assertNull(flight.miniCoverBitmap) + assertNull("an active linked morph must stop drawing the removed cover", flight.linkedBitmap) + assertFalse(flight.hidesCovers) + } + + @Test + fun clearingArtworkDuringAFlightRemovesItsStartedBitmapAndPreservesGeometry() = runTest { + val bitmap = ImageBitmap(2, 2) + val sourceBounds = Rect(12f, 720f, 60f, 768f) + val containerBounds = Rect(0f, 700f, 400f, 780f) + val flight = SharedCoverFlightState().apply { + reportSource(sourceBounds, 8f, bitmap) + reportMiniPlayerContainer(containerBounds) + reportTarget(Rect(20f, 100f, 380f, 460f), 24f) + } + try { + flight.open(backgroundScope, bitmap) + assertTrue(flight.flying) + assertTrue(flight.hidesCovers) + assertSame(bitmap, flight.flightBitmap) + val frameBeforeClear = flight.currentFrame() + + assertNull(resolveNowPlayingOverlayArtwork("overlay-cleared-active-flight", flight, clearCover = true)) + + assertEquals(sourceBounds, flight.miniCoverBounds) + assertEquals(containerBounds, flight.miniPlayerContainerBounds) + assertEquals(frameBeforeClear, flight.currentFrame()) + assertTrue("clearing artwork must preserve the active flight owner", flight.flying) + assertNull(flight.miniCoverBitmap) + assertNull("an active flight must stop drawing the removed cover", flight.flightBitmap) + assertFalse("an empty flight must not hide the no-artwork placeholder", flight.hidesCovers) + } finally { + flight.cancel() + } + } } diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackCoverSourceTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackCoverSourceTest.kt new file mode 100644 index 0000000000..34733bf858 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackCoverSourceTest.kt @@ -0,0 +1,46 @@ +package moe.ouom.neriplayer.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.ui.playback.visual.resolveUiCoverSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppPlaybackCoverSourceTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun realCoverResolverKeepsSelectedCoversAndRecognizesRestoredEmptyLocalArtwork() { + val local = SongItem( + id = 74_128L, + name = "Restored local cover", + artist = "Artist", + album = "Album", + albumId = 0L, + durationMs = 60_000L, + coverUrl = null, + mediaUri = "content://real-cover-resolver/audio/74128", + originalCoverUrl = "", + channelId = "local", + audioId = "74128" + ) + + assertNull(local.resolveUiCoverSource(context)) + assertNull(local.copy(coverUrl = " ", customCoverUrl = "").resolveUiCoverSource(context)) + assertEquals( + "content://real-cover-resolver/manual-cover", + local.copy(customCoverUrl = "content://real-cover-resolver/manual-cover").resolveUiCoverSource(context) + ) + assertEquals( + "neri-cover://current-local-cover", + local.copy(coverUrl = "neri-cover://current-local-cover").resolveUiCoverSource(context) + ) + val noSong: SongItem? = null + assertNull(noSong.resolveUiCoverSource(context)) + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackVisualOwnerTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackVisualOwnerTest.kt index 6e2163a791..3587340977 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackVisualOwnerTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/AppPlaybackVisualOwnerTest.kt @@ -28,6 +28,47 @@ import org.junit.Test import org.mockito.Mockito.mock class AppPlaybackVisualOwnerTest { + @Test + fun restoredKnownEmptyArtworkClearsTheVisualCoverWithoutChangingOrdinaryNullRetention() { + val previous = PlaybackVisualCoverState("edited-cover", "same-song") + assertEquals(previous, resolvePlaybackVisualCoverState(null, previous, "same-song", true)) + + val restored = playbackVisualCoverRequest("stale-parent-cover", "same-song", clearCover = true) + assertNull(restored.coverUrl) + assertEquals( + PlaybackVisualCoverState(null, "same-song"), + resolvePlaybackVisualCoverState( + currentCoverUrl = restored.coverUrl, + previousState = previous, + currentSongKey = restored.songKey, + hasCurrentSong = true, + clearCover = restored.clearCover + ) + ) + } + + @Test + fun explicitClearDropsInactiveRetainedArtworkEvenWhenAStaleCoverIsSupplied() { + val previous = PlaybackVisualCoverState("edited-cover", "old-song") + assertEquals( + PlaybackVisualCoverState(null, null), + resolvePlaybackVisualCoverState("stale-cover", previous, null, false, clearCover = true) + ) + assertEquals( + PlaybackVisualCoverState(null, null), + resolvePlaybackVisualCoverState(null, previous, null, false) + ) + assertEquals(previous, resolvePlaybackVisualCoverState(null, previous, "old-song", true)) + assertEquals( + PlaybackVisualCoverState(null, "restored-song"), + resolvePlaybackVisualCoverState(null, previous, "restored-song", false, clearCover = true) + ) + assertEquals( + PlaybackVisualCoverState(null, null), + resolvePlaybackVisualCoverState(" ", null, null, false) + ) + } + @Test fun accentLoadingEmitsCachedAndFreshSamplesButDropsStaleRequests() = runTest { val cached = CoverArtColorSample("#111111", 0xFF111111.toInt()) diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurEffectOwnerTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurEffectOwnerTest.kt index 5da02339ca..d00e4e8c9c 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurEffectOwnerTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurEffectOwnerTest.kt @@ -19,7 +19,9 @@ import moe.ouom.neriplayer.ui.playback.visual.nowPlayingBackdropFrame import moe.ouom.neriplayer.ui.playback.visual.nowPlayingBlurImageCacheKey import moe.ouom.neriplayer.ui.playback.visual.nowPlayingBlurNeighborCover import moe.ouom.neriplayer.ui.playback.visual.nowPlayingBlurPreloadRequest +import moe.ouom.neriplayer.ui.util.hasKnownEmptyLocalCover import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Test @@ -204,6 +206,46 @@ class NowPlayingBlurEffectOwnerTest { assertNull(state.stableCoverUrl) } + @Test + fun `restoring no artwork clears the backdrop owner and rejects its late image result`() = runTest { + val song = SongItem( + id = 74_126L, + name = "Restored backdrop", + artist = "Artist", + album = "Album", + albumId = 0L, + durationMs = 60_000L, + coverUrl = null, + mediaUri = "content://backdrop-restore/audio/74126", + originalCoverUrl = "", + channelId = "local", + audioId = "74126" + ) + val songKey = song.playbackVisualKey() + val editedCover = "file:///backdrop-restore/edited.jpg" + val owner = NowPlayingBlurEffectOwner(this) + val state = owner.blurState + state.onImageSuccess("edited-request", "edited-request", editedCover, 4f) + owner.updateRetention(NowPlayingBlurRetentionRequest(true, true, songKey, null)) + runCurrent() + assertEquals("a transient missing URL keeps the backdrop", editedCover, state.stableCoverUrl) + + owner.updateRetention( + NowPlayingBlurRetentionRequest(true, true, songKey, null, song.hasKnownEmptyLocalCover()) + ) + runCurrent() + + assertNull("a restored empty cover clears the stable backdrop", state.stableCoverUrl) + assertNull(state.stableBlurStrength) + state.onImageSuccess("edited-request", "edited-request", editedCover, 4f) + assertNull("a late image callback cannot restore the old backdrop", state.stableCoverUrl) + val frame = nowPlayingBackdropFrame( + NowPlayingOverlayCover(null, songKey, song, 0), + NowPlayingOverlayBackground(true, 4f, 0f, false, true), state, true + ) + assertFalse("the real backdrop frame must stop drawing retained blur", frame.hasCoverBlur) + } + @Test fun `forgetting backdrop cancels pending cover clear`() = runTest { val owner = NowPlayingBlurEffectOwner(backgroundScope) diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurStateTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurStateTest.kt index 06150a1875..10bc572af1 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurStateTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/NowPlayingBlurStateTest.kt @@ -84,4 +84,36 @@ class NowPlayingBlurStateTest { state.reconcileRetention(NowPlayingBlurRetentionRequest(true, true, "song", null)) assertEquals("cover", state.stableCoverUrl) } + + @Test + fun `explicit empty artwork clears retained blur for every availability and preference combination`() = runTest { + for (available in listOf(false, true)) { + for (enabled in listOf(false, true)) { + val state = NowPlayingBlurState() + state.onImageSuccess("edited", "edited", "edited-cover", 4f) + state.observeRequest("same-song", "stale-parent-cover") + + state.reconcileRetention( + NowPlayingBlurRetentionRequest(available, enabled, "same-song", "stale-parent-cover", true) + ) + + val scenario = "available=$available enabled=$enabled" + assertNull(scenario, state.stableCoverUrl) + assertNull(scenario, state.stableBlurStrength) + assertFalse(scenario, state.loadFailed) + } + } + } + + @Test + fun `blank cover placeholder clears retained blur after playback ends`() = runTest { + val state = NowPlayingBlurState() + state.onImageSuccess("edited", "edited", "edited-cover", 4f) + state.observeRequest(null, " \t ") + + state.reconcileRetention(NowPlayingBlurRetentionRequest(true, true, null, " \t ")) + + assertNull(state.stableCoverUrl) + assertNull(state.stableBlurStrength) + } } diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgressTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgressTest.kt new file mode 100644 index 0000000000..30da7e7599 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationProgressTest.kt @@ -0,0 +1,160 @@ +package moe.ouom.neriplayer.ui.component.playback + +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import moe.ouom.neriplayer.data.model.settings.appearance.UiStyle +import moe.ouom.neriplayer.ui.theme.NeriTheme +import moe.ouom.neriplayer.ui.viewmodel.ManualMetadataApplicationState +import moe.ouom.neriplayer.ui.viewmodel.ManualSearchState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = "w360dp-h640dp-mdpi") +class ManualMetadataApplicationProgressTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private var darkTheme = false + + @Test fun `clean progress permits cancellation then reaches complete only after commit`() = verifyProgress(UiStyle.Clean) + @Test fun `material progress permits cancellation then reaches complete only after commit`() = verifyProgress(UiStyle.Material) + @Test fun `clean dark progress preserves insets and completion`() = verifyProgress(UiStyle.Clean, true) + @Test fun `material dark progress preserves insets and completion`() = verifyProgress(UiStyle.Material, true) + + @Test + fun `stage progress moves through intermediate frames instead of jumping`() { + val application = mutableStateOf( + ManualMetadataApplicationState(isRunning = true, stage = SongMetadataApplyStage.FETCHING_DETAILS) + ) + composeRule.setContent { + NeriTheme(false, false, false, "6750A4", uiStyle = UiStyle.Clean, animateColors = false) { + ManualMetadataApplicationProgress(application.value, {}) + } + } + composeRule.waitForIdle() + composeRule.mainClock.autoAdvance = false + composeRule.runOnIdle { application.value = application.value.copy(stage = SongMetadataApplyStage.APPLYING) } + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(96L) + composeRule.waitForIdle() + val progress = composeRule.onNodeWithTag("metadata-stage-progress").fetchSemanticsNode() + .config[SemanticsProperties.ProgressBarRangeInfo].current + assertTrue("progress must animate between stages, actual=$progress", progress > 0f && progress < 0.8f) + } + + @Test + fun `cancelling retains the outgoing progress stage during its fade`() { + val application = mutableStateOf( + ManualMetadataApplicationState(isRunning = true, stage = SongMetadataApplyStage.FETCHING_LYRICS) + ) + var completions = 0 + composeRule.setContent { + NeriTheme(false, false, false, "6750A4", uiStyle = UiStyle.Clean, animateColors = false) { + MetadataSearchResults( + state = ManualSearchState(), offlineMode = true, enabled = true, + isWaitingForPlayback = false, application = application.value, + onCancelApplication = { application.value = ManualMetadataApplicationState() }, + onSongSelected = {}, onApplied = { completions++ } + ) + } + } + val previous = composeRule.onNodeWithTag("metadata-application-stage").fetchSemanticsNode() + .config[SemanticsProperties.Text].single().text + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithTag("metadata-application-cancel").performClick() + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(64L) + composeRule.waitForIdle() + composeRule.onNodeWithTag("metadata-application-stage").assertTextEquals(previous) + composeRule.runOnIdle { assertEquals(0, completions) } + } + + private fun verifyProgress(style: UiStyle, dark: Boolean = false) { + darkTheme = dark + val application = mutableStateOf( + ManualMetadataApplicationState(isRunning = true, stage = SongMetadataApplyStage.FETCHING_LYRICS) + ) + var cancellations = 0 + composeRule.setContent { + NeriTheme(followSystemDark = false, forceDark = darkTheme, dynamicColor = false, + seedColorHex = "6750A4", uiStyle = style, animateColors = false) { + val surfaceIsDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + SideEffect { assertEquals("render must use the requested appearance", darkTheme, surfaceIsDark) } + Surface(Modifier.fillMaxWidth().testTag("metadata-progress-host"), color = MaterialTheme.colorScheme.surfaceContainer) { + MetadataSearchResults( + state = ManualSearchState(), offlineMode = true, enabled = false, + isWaitingForPlayback = false, application = application.value, + onCancelApplication = { cancellations++ }, onSongSelected = {} + ) + } + } + } + val host = composeRule.onNodeWithTag("metadata-results-host").getUnclippedBoundsInRoot() + val card = composeRule.onNodeWithTag("metadata-application-progress").getUnclippedBoundsInRoot() + assertEquals("progress must align with the search input inset", 16f, (card.left - host.left).value, 0.5f) + assertEquals("progress must keep equal side insets", 16f, (host.right - card.right).value, 0.5f) + composeRule.onNodeWithTag("metadata-application-cancel").assertIsEnabled().performClick() + composeRule.runOnIdle { + assertEquals(1, cancellations) + application.value = application.value.copy(stage = SongMetadataApplyStage.APPLYING) + } + composeRule.onNodeWithTag("metadata-application-cancel").assertIsNotEnabled() + composeRule.onNodeWithTag("metadata-stage-progress").assert( + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo(0.8f, 0f..1f)) + ) + capture(style, "applying") + composeRule.runOnIdle { + application.value = application.value.copy(isRunning = false, completed = true) + } + composeRule.onNodeWithTag("metadata-stage-progress").assert( + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo(1f, 0f..1f)) + ) + capture(style, "completed") + } + + private fun capture(style: UiStyle, stage: String) { + val directory = System.getProperty("neriplayer.metadataScreenshotDir") + ?: System.getenv("NERIPLAYER_METADATA_SCREENSHOT_DIR") ?: return + val target = File(directory).apply { mkdirs() } + val bitmap = composeRule.runOnIdle { + val view = composeRule.activity.window.decorView + Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888).also { view.draw(Canvas(it)) } + } + val appearance = if (darkTheme) "dark" else "light" + File(target, "metadata-progress-${style.name.lowercase()}-$appearance-$stage.png").outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationSheetTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationSheetTest.kt new file mode 100644 index 0000000000..66fcf789f1 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplicationSheetTest.kt @@ -0,0 +1,147 @@ +package moe.ouom.neriplayer.ui.component.playback + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Job +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import moe.ouom.neriplayer.data.model.settings.appearance.UiStyle +import moe.ouom.neriplayer.ui.haptic.HapticTextButton +import moe.ouom.neriplayer.ui.screen.nowplaying.MoreOptionsPage +import moe.ouom.neriplayer.ui.screen.nowplaying.MoreOptionsSheetOwner +import moe.ouom.neriplayer.ui.screen.nowplaying.MoreOptionsSheetSurface +import moe.ouom.neriplayer.ui.theme.NeriTheme +import moe.ouom.neriplayer.ui.viewmodel.ManualMetadataApplicationOwner +import moe.ouom.neriplayer.ui.viewmodel.ManualSearchState +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = "w360dp-h640dp-mdpi") +class ManualMetadataApplicationSheetTest { + @get:Rule val composeRule = createAndroidComposeRule() + private val application = ManualMetadataApplicationOwner() + private var complete: (Boolean) -> Unit = {} + private var dismissals = 0 + private var completionRequests = 0 + + @Test + fun `cancelling preparation keeps the search open and rejects late success`() { + setSheet(SongMetadataApplyStage.FETCHING_LYRICS) + composeRule.onNodeWithTag("metadata-application-cancel").performClick() + composeRule.runOnIdle { complete(true) } + + composeRule.onNodeWithTag("metadata-search-results").assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, dismissals) } + } + + @Test + fun `applying blocks closing and failure leaves the sheet available`() { + setSheet(SongMetadataApplyStage.APPLYING) + composeRule.onNodeWithTag("metadata-test-close").performClick() + composeRule.onNodeWithTag("metadata-application-cancel").assertIsNotEnabled() + composeRule.onNodeWithTag("metadata-stage-progress").assertIsDisplayed() + composeRule.runOnIdle { + assertEquals(0, dismissals) + complete(false) + } + + composeRule.onNodeWithTag("metadata-application-failure").assertIsDisplayed() + composeRule.onNodeWithTag("metadata-search-results").assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, dismissals) } + } + + @Test + fun `successful application renders completion before closing`() { + setSheet(SongMetadataApplyStage.APPLYING) + composeRule.mainClock.autoAdvance = false + val runningBounds = composeRule.onNodeWithTag("metadata-application-progress").getUnclippedBoundsInRoot() + composeRule.runOnIdle { complete(true) } + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + composeRule.onNodeWithTag("metadata-stage-progress").assertIsDisplayed() + val completedBounds = composeRule.onNodeWithTag("metadata-application-progress").getUnclippedBoundsInRoot() + assertEquals("completion must not collapse the progress card", + (runningBounds.bottom - runningBounds.top).value, (completedBounds.bottom - completedBounds.top).value, 0.5f) + composeRule.mainClock.advanceTimeBy(240L) + composeRule.waitForIdle() + composeRule.runOnIdle { assertEquals(0, dismissals) } + repeat(80) { + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + } + composeRule.runOnIdle { + assertEquals("completion must request closing once", 1, completionRequests) + assertEquals("sheet hide must finish after the completion presentation", 1, dismissals) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + private fun setSheet(stage: SongMetadataApplyStage) { + application.start { progress, completion -> + complete = completion + progress(stage) + Job() + } + composeRule.setContent { + NeriTheme(false, false, false, "6750A4", uiStyle = UiStyle.Clean, animateColors = false) { + val state by application.state.collectAsState() + val scope = rememberCoroutineScope() + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val owner = remember { + MoreOptionsSheetOwner(scope, sheet::hide, sheet::expand, MoreOptionsPage.SEARCH) { dismissals++ } + } + val applying = state.isRunning + SideEffect { owner.setMetadataApplying(applying) } + MoreOptionsSheetSurface(owner, sheet, remember { SnackbarHostState() }) { + Column(Modifier.padding(16.dp)) { + if (state.failed) Text("Apply failed", Modifier.testTag("metadata-application-failure")) + MetadataSearchResults( + state = ManualSearchState(searchResults = listOf(SongSearchInfo( + id = "fixture", songName = "Synthetic song", singer = "Synthetic artist", + duration = "03:00", albumName = "Synthetic album", coverUrl = null, + source = MusicPlatform.CLOUD_MUSIC + ))), + offlineMode = true, enabled = !state.isRunning, isWaitingForPlayback = false, + application = state, onCancelApplication = { application.cancel() }, + onSongSelected = {}, requestKey = "fixture", onApplied = { + completionRequests++ + owner.dismiss() + } + ) + HapticTextButton(onClick = owner::dismiss, modifier = Modifier.testTag("metadata-test-close")) { + Text("Close") + } + } + } + } + } + composeRule.waitForIdle() + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyDialogTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyDialogTest.kt new file mode 100644 index 0000000000..2cd8963b31 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyDialogTest.kt @@ -0,0 +1,273 @@ +package moe.ouom.neriplayer.ui.component.playback + +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import moe.ouom.neriplayer.data.model.settings.appearance.UiStyle +import moe.ouom.neriplayer.ui.theme.NeriTheme +import moe.ouom.neriplayer.ui.screen.nowplaying.lyrics.stringResId +import moe.ouom.neriplayer.R as AppR +import moe.ouom.neriplayer.common.R as CoreCommonR +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowDialog + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = "zh-rCN-w360dp-h640dp-mdpi") +class ManualMetadataApplyDialogTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private val originalFontScale = RuntimeEnvironment.getFontScale() + private val dialogVisible = mutableStateOf(true) + private var darkTheme = false + + @After + fun restoreFontScale() { + composeRule.runOnIdle { dialogVisible.value = false } + composeRule.waitForIdle() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + RuntimeEnvironment.setFontScale(originalFontScale) + } + } + + @Test fun `clean dialog keeps every choice reachable when saving in app`() = verifySelection(UiStyle.Clean, false) + @Test fun `material dialog keeps every choice reachable when saving in app`() = verifySelection(UiStyle.Material, false) + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `clean dialog confirms file writeback after destination selection`() = verifySelection(UiStyle.Clean, true) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `material dialog confirms file writeback after destination selection`() = verifySelection(UiStyle.Material, true) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `clean dark dialog preserves selection and fixed actions`() = verifySelection(UiStyle.Clean, false, true) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `material dark dialog preserves selection and fixed actions`() = verifySelection(UiStyle.Material, true, true) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `clean sources use aligned equal width columns`() = verifySourceColumns(UiStyle.Clean) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `material sources use aligned equal width columns`() = verifySourceColumns(UiStyle.Material) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `clean lyric choices have separate rows and a separate save group`() = verifyContentGroups(UiStyle.Clean) + + @Test + @Config(qualifiers = "en-w360dp-h640dp-mdpi") + fun `material lyric choices have separate rows and a separate save group`() = verifyContentGroups(UiStyle.Material) + + @Test + @Config(qualifiers = "en-w320dp-h568dp-mdpi") + fun `clean narrow large text choices stay complete with fixed actions`() = verifyLargeText(UiStyle.Clean) + + @Test + @Config(qualifiers = "en-w320dp-h568dp-mdpi") + fun `material narrow large text choices stay complete with fixed actions`() = verifyLargeText(UiStyle.Material) + + private fun verifySelection(style: UiStyle, writeLocalMetadata: Boolean, dark: Boolean = false) { + var applied: SongMetadataApplyOptions? = null + darkTheme = dark + showOptions(style) { applied = it } + composeRule.onNodeWithTag("metadata-apply-confirm").assertIsDisplayed() + composeRule.onNodeWithTag("metadata-apply-cancel").assertIsDisplayed() + listOf("metadata-apply-write", "metadata-apply-app-only").forEach { tag -> + val choice = composeRule.onNodeWithTag(tag).assertIsDisplayed() + val visible = choice.getBoundsInRoot() + val full = choice.getUnclippedBoundsInRoot() + assertEquals("destination must be fully visible before scrolling: $tag", + (full.bottom - full.top).value, (visible.bottom - visible.top).value, 0.5f) + assertEquals("destination must fit the dialog width: $tag", + (full.right - full.left).value, (visible.right - visible.left).value, 0.5f) + } + capture(style, "sources") + EditableLyricMatchSource.entries.forEach { source -> + composeRule.onNodeWithTag("metadata-source-${source.name}").performScrollTo().assertIsDisplayed() + } + composeRule.onNodeWithTag("metadata-source-KUGOU").performScrollTo().performClick() + composeRule.onNodeWithTag("metadata-content-original").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithTag("metadata-content-translated").performScrollTo().performClick() + composeRule.onNodeWithTag("metadata-content-romanized").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithTag("metadata-prefer-word-timed").performScrollTo().assertIsOn() + .assertTextContains(composeRule.activity.getString(CoreCommonR.string.settings_prefer_word_timed_lyrics)) + .performClick() + composeRule.onNodeWithTag("metadata-apply-app-only").assertIsDisplayed().performClick() + if (writeLocalMetadata) { + composeRule.onNodeWithTag("metadata-apply-write").assertIsDisplayed().performClick() + } + composeRule.runOnIdle { assertNull("choosing a destination must not apply metadata", applied) } + composeRule.onNodeWithTag("metadata-apply-cancel").assertIsDisplayed() + capture(style, "preferences") + composeRule.onNodeWithTag("metadata-apply-confirm").assertIsDisplayed().performClick() + + composeRule.runOnIdle { + val options = requireNotNull(applied) + assertEquals( + setOf(EditableLyricMatchSource.CLOUD_MUSIC, EditableLyricMatchSource.KUGOU), + options.lyricSources + ) + assertTrue(options.originalLyrics) + assertFalse(options.translatedLyrics) + assertTrue(options.romanizedLyrics) + assertFalse(options.preferWordTimed) + assertEquals(writeLocalMetadata, options.writeLocalMetadata) + } + } + + private fun showOptions(style: UiStyle, onApply: (SongMetadataApplyOptions) -> Unit = {}) { + composeRule.setContent { + if (!dialogVisible.value) return@setContent + NeriTheme(followSystemDark = false, forceDark = darkTheme, dynamicColor = false, + seedColorHex = "6750A4", uiStyle = style, animateColors = false) { + val surfaceIsDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + SideEffect { assertEquals("render must use the requested appearance", darkTheme, surfaceIsDark) } + ManualMetadataApplyOptionsDialog( + selectedSong = SongSearchInfo( + id = "42", songName = "Synthetic song", singer = "Synthetic artist", duration = "03:00", + albumName = "Synthetic album", coverUrl = null, source = MusicPlatform.CLOUD_MUSIC + ), + isLocalSong = true, + initialOptions = SongMetadataApplyOptions( + lyricSources = setOf(EditableLyricMatchSource.CLOUD_MUSIC) + ), + onDismiss = {}, + onApply = onApply + ) + } + } + } + + private fun verifySourceColumns(style: UiStyle) { + showOptions(style) + assertSourceColumns() + } + + private fun assertSourceColumns() { + val rows = EditableLyricMatchSource.entries.chunked(2).map { sources -> + composeRule.onNodeWithTag("metadata-source-${sources.first().name}").performScrollTo() + sources.map { source -> composeRule.onNodeWithTag("metadata-source-${source.name}").getUnclippedBoundsInRoot() } + } + rows.forEach { (left, right) -> + assertEquals("source columns must have equal widths", (left.right - left.left).value, (right.right - right.left).value, 0.5f) + assertEquals("source choices must align by row", left.top.value, right.top.value, 0.5f) + assertEquals("source choices must share row height", left.bottom.value, right.bottom.value, 0.5f) + assertTrue("source columns need a visible gap", right.left.value - left.right.value >= 7.5f) + } + } + + private fun verifyContentGroups(style: UiStyle) { + showOptions(style) + composeRule.onNodeWithTag("metadata-content-original").performScrollTo() + val rows = listOf("original", "translated", "romanized").map { content -> + composeRule.onNodeWithTag("metadata-content-$content").getUnclippedBoundsInRoot() + } + rows.zipWithNext().forEach { (previous, next) -> + assertTrue("each lyric content choice needs its own row", next.top >= previous.bottom) + assertEquals(previous.left.value, next.left.value, 0.5f) + assertEquals(previous.right.value, next.right.value, 0.5f) + } + assertDestinationGroup() + } + + private fun assertDestinationGroup() { + val heading = composeRule.onNodeWithText(composeRule.activity.getString(AppR.string.metadata_apply_destination), useUnmergedTree = true).getUnclippedBoundsInRoot() + val write = composeRule.onNodeWithTag("metadata-apply-write").assertIsDisplayed().getUnclippedBoundsInRoot() + val app = composeRule.onNodeWithTag("metadata-apply-app-only").assertIsDisplayed().getUnclippedBoundsInRoot() + assertTrue("save destination heading needs its own line and spacing", write.top.value - heading.bottom.value >= 7.5f) + assertEquals("save destinations must share one row", write.top.value, app.top.value, 0.5f) + assertEquals("save destinations must have equal widths", (write.right - write.left).value, (app.right - app.left).value, 0.5f) + } + + private fun verifyLargeText(style: UiStyle) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { RuntimeEnvironment.setFontScale(1.5f) } + showOptions(style) + val actions = listOf("metadata-apply-confirm", "metadata-apply-cancel").associateWith { tag -> + composeRule.onNodeWithTag(tag).assertIsDisplayed().getUnclippedBoundsInRoot() + } + EditableLyricMatchSource.entries.forEach { source -> + composeRule.onNodeWithTag("metadata-source-${source.name}").performScrollTo().assertIsDisplayed() + assertCompleteLabel(composeRule.onNodeWithText(composeRule.activity.getString(source.stringResId()), useUnmergedTree = true)) + } + listOf("metadata-content-original", "metadata-content-translated", "metadata-content-romanized", "metadata-prefer-word-timed").forEach { tag -> + composeRule.onNodeWithTag(tag).performScrollTo().assertIsDisplayed() + } + assertDestinationGroup() + listOf(AppR.string.metadata_apply_destination_file, AppR.string.metadata_apply_destination_app).forEach { id -> + assertCompleteLabel(composeRule.onNodeWithText(composeRule.activity.getString(id), useUnmergedTree = true)) + } + actions.forEach { (tag, bounds) -> + assertEquals("dialog actions must remain fixed while options scroll", bounds, composeRule.onNodeWithTag(tag).assertIsDisplayed().getUnclippedBoundsInRoot()) + } + capture(style, "large-text") + } + + private fun assertCompleteLabel(node: SemanticsNodeInteraction) { + val layouts = mutableListOf() + node.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + val layout = layouts.single() + assertEquals(1.5f, layout.layoutInput.density.fontScale, 0f) + assertFalse("choice labels must not exceed their line limit", layout.multiParagraph.didExceedMaxLines) + for (line in 0 until layout.lineCount) { + assertFalse("choice labels must not be ellipsized", layout.isLineEllipsized(line)) + assertTrue("choice text must paint within its measured width", layout.getLineRight(line) <= layout.size.width + 1f) + } + } + + private fun capture(style: UiStyle, section: String) { + val directory = System.getProperty("neriplayer.metadataScreenshotDir") + ?: System.getenv("NERIPLAYER_METADATA_SCREENSHOT_DIR") ?: return + val target = File(directory).apply { mkdirs() } + val bitmap = composeRule.runOnIdle { + val view = requireNotNull(ShadowDialog.getLatestDialog().window).decorView + Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888).also { view.draw(Canvas(it)) } + } + val language = composeRule.activity.resources.configuration.locales[0].language + val appearance = if (darkTheme) "dark" else "light" + File(target, "metadata-options-${style.name.lowercase()}-$language-$appearance-$section-${bitmap.width}x${bitmap.height}.png").outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptionsTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptionsTest.kt new file mode 100644 index 0000000000..f1005f8177 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/ManualMetadataApplyOptionsTest.kt @@ -0,0 +1,51 @@ +package moe.ouom.neriplayer.ui.component.playback + +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.settings.lyrics.LyricSourcePreference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManualMetadataApplyOptionsTest { + @Test + fun `explicit lyric preference overrides the metadata search platform`() { + assertEquals( + setOf(EditableLyricMatchSource.KUGOU), + defaultMetadataLyricSources(LyricSourcePreference.Kugou, MusicPlatform.CLOUD_MUSIC) + ) + } + + @Test + fun `automatic lyrics follow the selected metadata platform`() { + assertEquals( + setOf(EditableLyricMatchSource.QQ_MUSIC), + defaultMetadataLyricSources(LyricSourcePreference.Automatic, MusicPlatform.QQ_MUSIC) + ) + assertEquals( + setOf(EditableLyricMatchSource.CLOUD_MUSIC), + defaultMetadataLyricSources(LyricSourcePreference.Automatic, MusicPlatform.CLOUD_MUSIC) + ) + } + + @Test + fun `requesting any lyric content requires a selected source`() { + assertFalse(canApplyManualMetadataSelection(SongMetadataApplyOptions(lyricSources = emptySet()))) + } + + @Test + fun `keeping every lyric field permits metadata without a lyric source`() { + assertTrue( + canApplyManualMetadataSelection( + SongMetadataApplyOptions( + lyricSources = emptySet(), + originalLyrics = false, + translatedLyrics = false, + romanizedLyrics = false + ) + ) + ) + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayerHighResolutionArtworkTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayerHighResolutionArtworkTest.kt index 719ddab6fb..cab47fa31e 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayerHighResolutionArtworkTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/component/playback/NeriMiniPlayerHighResolutionArtworkTest.kt @@ -19,13 +19,18 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.Coil import coil.ImageLoader +import coil.intercept.Interceptor import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import moe.ouom.neriplayer.ui.animation.LocalSharedCoverFlight import moe.ouom.neriplayer.ui.animation.SharedCoverFlightState import moe.ouom.neriplayer.ui.playback.visual.resolveNowPlayingOverlayArtwork import moe.ouom.neriplayer.util.media.RetainedPlaybackCoverBitmapCache import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Rule import org.junit.Test @@ -78,6 +83,70 @@ class NeriMiniPlayerHighResolutionArtworkTest { assertFullResolutionArtwork(covers.first(), awaitArtwork(covers.first())) } + @Test + fun restoredNoArtworkClearsTheMountedMiniAndRejectsLateSharedFrames() { + val requestStarted = CompletableDeferred() + val releaseRequest = CompletableDeferred() + val requestFinished = CompletableDeferred() + try { + withLocalCovers(configureLoader = { covers -> + components { + add(Interceptor { chain -> + if (chain.request.data.toString() == covers.last().url) { + requestStarted.complete(Unit) + withContext(NonCancellable) { + releaseRequest.await() + try { + chain.proceed(chain.request) + } finally { + requestFinished.complete(Unit) + } + } + } else { + chain.proceed(chain.request) + } + }) + } + }) { covers -> + val edited = covers.first() + val current = mutableStateOf(edited) + render(current, tablet = false) + val oldBitmap = awaitArtwork(edited) + + composeRule.runOnIdle { current.value = edited.copy(requestCover = false) } + composeRule.waitForIdle() + composeRule.runOnIdle { assertSame(oldBitmap, flight.miniCoverBitmap) } + + composeRule.runOnIdle { current.value = edited.copy(url = covers.last().url) } + composeRule.waitUntil(timeoutMillis = 10_000L) { + composeRule.runOnIdle { requestStarted.isCompleted } + } + composeRule.runOnIdle { + current.value = edited.copy(requestCover = false, clearCover = true) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertNull("restoring no artwork must clear the mounted mini", flight.miniCoverBitmap) + } + + releaseRequest.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { + composeRule.runOnIdle { requestFinished.isCompleted } + } + composeRule.runOnIdle { + RetainedPlaybackCoverBitmapCache.put(edited.ownerKey, edited.url, null, oldBitmap) + current.value = current.value.copy(revision = 1) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertNull("late artwork must not restore a cleared mini", flight.miniCoverBitmap) + } + } + } finally { + releaseRequest.complete(Unit) + } + } + private fun render(current: MutableState, tablet: Boolean) { composeRule.setContent { if (mounted) { @@ -85,10 +154,11 @@ class NeriMiniPlayerHighResolutionArtworkTest { CompositionLocalProvider(LocalSharedCoverFlight provides flight) { MaterialTheme { NeriMiniPlayer( - title = cover.ownerKey, + title = cover.ownerKey + if (cover.revision == 0) "" else " ${cover.revision}", artist = "Artwork fixture", - coverUrl = cover.url, + coverUrl = cover.url.takeIf { cover.requestCover }, coverIdentityKey = cover.ownerKey, + clearCover = cover.clearCover, isPlaying = false, onPlayPause = {}, onPrevious = {}, @@ -146,11 +216,14 @@ class NeriMiniPlayerHighResolutionArtworkTest { } } - private fun withLocalCovers(test: (List) -> Unit) { + private fun withLocalCovers( + configureLoader: ImageLoader.Builder.(List) -> Unit = {}, + test: (List) -> Unit + ) { val directory = File(context.cacheDir, "mini-player-highres-${System.nanoTime()}") check(directory.mkdir()) val savedImageLoader = Coil.imageLoader(context) - val imageLoader = ImageLoader.Builder(context).memoryCache(null).diskCache(null).build() + var imageLoader: ImageLoader? = null try { val covers = listOf(Color.RED, Color.GREEN).mapIndexed { index, color -> val file = File(directory, "cover-$index.png") @@ -163,6 +236,8 @@ class NeriMiniPlayerHighResolutionArtworkTest { } CoverFixture("${directory.name}-$index", Uri.fromFile(file).toString(), color) } + imageLoader = ImageLoader.Builder(context).memoryCache(null).diskCache(null) + .apply { configureLoader(covers) }.build() Coil.setImageLoader(imageLoader) test(covers) } finally { @@ -172,7 +247,7 @@ class NeriMiniPlayerHighResolutionArtworkTest { } finally { try { Coil.setImageLoader(savedImageLoader) - imageLoader.shutdown() + imageLoader?.shutdown() } finally { check(directory.deleteRecursively()) } @@ -180,5 +255,12 @@ class NeriMiniPlayerHighResolutionArtworkTest { } } - private data class CoverFixture(val ownerKey: String, val url: String, val color: Int) + private data class CoverFixture( + val ownerKey: String, + val url: String, + val color: Int, + val requestCover: Boolean = true, + val clearCover: Boolean = false, + val revision: Int = 0 + ) } diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropArtworkTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropArtworkTest.kt new file mode 100644 index 0000000000..496c33315c --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/playback/visual/NowPlayingBackdropArtworkTest.kt @@ -0,0 +1,181 @@ +package moe.ouom.neriplayer.ui.playback.visual + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.net.Uri +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.Coil +import coil.ImageLoader +import java.io.File +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NowPlayingBackdropArtworkTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var hostView: View + + @Test + fun knownEmptyArtworkRemovesMountedRetainedBlurAndDoesNotMountTheDynamicRenderer() { + val context = ApplicationProvider.getApplicationContext() + val file = File(context.cacheDir, "backdrop-clear-${System.nanoTime()}.png") + val sourceBitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + try { + sourceBitmap.eraseColor(Color.RED) + file.outputStream().use { check(sourceBitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + sourceBitmap.recycle() + } + val oldCoverUrl = Uri.fromFile(file).toString() + val savedLoader = Coil.imageLoader(context) + val loader = ImageLoader.Builder(context).memoryCache(null).diskCache(null).build() + val mounted = mutableStateOf(true) + val backdropLifecycle = composeRule.runOnIdle { + object : LifecycleOwner { + override val lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.STARTED } + } + } + val restored = SongItem( + id = 74_129L, + name = "Backdrop clear", + artist = "Artist", + album = "Album", + albumId = 0L, + durationMs = 60_000L, + coverUrl = null, + mediaUri = "content://backdrop-clear/audio/74129", + originalCoverUrl = "", + channelId = "local", + audioId = "74129" + ) + val blurState = NowPlayingBlurState().apply { + onImageSuccess("edited", "edited", oldCoverUrl, 0f) + } + val pendingCover = NowPlayingOverlayCover(null, "backdrop-clear-song", restored.copy(originalCoverUrl = null), 0) + val frame = mutableStateOf( + NowPlayingBackdropFrame( + pendingCover, + NowPlayingOverlayBackground(true, 0f, 0f, true, true), + blurState, + hasCoverBlur = true, + blurStrength = 0f, + imageSizePx = 96, + assetVersion = "backdrop-clear", + requestKey = null, + latestRequestKey = null + ) + ) + try { + Coil.setImageLoader(loader) + composeRule.setContent { + hostView = LocalView.current + if (mounted.value) { + CompositionLocalProvider(LocalLifecycleOwner provides backdropLifecycle) { + Box(Modifier.size(96.dp).testTag("backdrop-clear")) { + NowPlayingBackdropLayers(frame.value) + } + } + } + } + val bounds = composeRule.onNodeWithTag("backdrop-clear").fetchSemanticsNode().boundsInRoot + val x = bounds.center.x.toInt() + val y = bounds.center.y.toInt() + composeRule.waitUntil(timeoutMillis = 10_000L) { + composeRule.runOnIdle { samplePixel(x, y) == Color.RED } + } + val baselineViews = composeRule.runOnIdle { plainViews(hostView).toSet() } + composeRule.runOnIdle { + assertEquals("a pending URL must keep the actually decoded backdrop", Color.RED, samplePixel(x, y)) + frame.value = frame.value.copy(cover = pendingCover.copy(url = oldCoverUrl, song = restored)) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertNotEquals("explicitly empty artwork must stop painting retained blur", Color.RED, samplePixel(x, y)) + assertEquals("the dynamic renderer must not replace the cleared blur with old artwork", baselineViews, plainViews(hostView).toSet()) + frame.value = frame.value.copy( + hasCoverBlur = false, + background = frame.value.background.copy(blurEnabled = false) + ) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertEquals("explicitly empty artwork also suppresses the standalone dynamic layer", baselineViews, plainViews(hostView).toSet()) + assertNotEquals(Color.RED, samplePixel(x, y)) + frame.value = frame.value.copy(cover = pendingCover.copy(url = oldCoverUrl)) + } + composeRule.waitForIdle() + val renderer = composeRule.runOnIdle { + val addedViews = plainViews(hostView).filterNot { it in baselineViews } + assertEquals("the ordinary dynamic background must mount one real renderer", 1, addedViews.size) + addedViews.single().also { + frame.value = frame.value.copy(cover = pendingCover.copy(url = oldCoverUrl, song = restored)) + } + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertEquals("restoring no artwork must remove the previously mounted renderer", baselineViews, plainViews(hostView).toSet()) + assertFalse("the old renderer must be detached so it cannot paint the removed artwork", renderer.isAttachedToWindow) + assertNotEquals(Color.RED, samplePixel(x, y)) + } + } finally { + try { + composeRule.runOnIdle { + mounted.value = false + backdropLifecycle.lifecycle.currentState = Lifecycle.State.DESTROYED + } + composeRule.waitForIdle() + } finally { + Coil.setImageLoader(savedLoader) + loader.shutdown() + check(file.delete()) + } + } + } + + private fun samplePixel(x: Int, y: Int): Int { + val bitmap = Bitmap.createBitmap(hostView.width, hostView.height, Bitmap.Config.ARGB_8888) + return try { + hostView.draw(Canvas(bitmap)) + bitmap.getPixel(x, y) + } finally { + bitmap.recycle() + } + } + + private fun plainViews(view: View): List = buildList { + if (view.javaClass == View::class.java) add(view) + if (view is ViewGroup) { + for (index in 0 until view.childCount) addAll(plainViews(view.getChildAt(index))) + } + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/LocalMetadataWriteBackConfirmationPolicyTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/LocalMetadataWriteBackConfirmationPolicyTest.kt index f54e4dcae8..14e5e12549 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/LocalMetadataWriteBackConfirmationPolicyTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/LocalMetadataWriteBackConfirmationPolicyTest.kt @@ -10,53 +10,60 @@ class LocalMetadataWriteBackConfirmationPolicyTest { @Test fun `asks before writing changed local metadata`() { - val song = localSong() + val song = localSong().copy(customName = "Updated title") - assertTrue( - shouldConfirmLocalMetadataWriteBack( - song = song, - title = "Updated title", - artist = song.artist, - coverUrl = song.coverUrl.orEmpty() - ) - ) + assertTrue(shouldConfirmLocalMetadataWriteBack(song)) } @Test fun `asks before writing changed local cover`() { - val song = localSong().copy(coverUrl = "file:///old-cover.jpg") + val song = localSong().copy(customCoverUrl = "file:///new-cover.jpg") + + assertTrue(shouldConfirmLocalMetadataWriteBack(song)) + } + + @Test + fun `reopened local edits ask again for existing app metadata overrides`() { + val song = localSong() + val overrides = listOf( + song.copy(customName = "App title"), + song.copy(customArtist = "App artist"), + song.copy(customCoverUrl = "file:///app-cover.jpg"), + song.copy(matchedLyric = "App lyric", lyricSyncEdited = true), + song.copy(matchedTranslatedLyric = "App translation", lyricSyncEdited = true), + song.copy(matchedRomanizedLyric = "App romanization", lyricSyncEdited = true), + song.copy(matchedLyric = "", lyricSyncEdited = true) + ) - assertTrue( - shouldConfirmLocalMetadataWriteBack( - song = song, - title = song.name, - artist = song.artist, - coverUrl = "file:///new-cover.jpg" + overrides.forEach { edited -> + assertTrue( + "existing app overrides must still offer file writeback: $edited", + shouldConfirmLocalMetadataWriteBack(edited) ) + } + } + + @Test + fun `local saves ask for a destination even when scanned lyrics are unchanged`() { + val song = localSong().copy( + matchedLyric = "Embedded lyric", + originalLyric = "Embedded lyric", + matchedTranslatedLyric = "Embedded translation", + originalTranslatedLyric = "Embedded translation", + matchedRomanizedLyric = "Embedded romanization", + originalRomanizedLyric = "Embedded romanization" ) + + assertTrue(shouldConfirmLocalMetadataWriteBack(song)) } @Test - fun `does not ask for unchanged or remote song metadata`() { + fun `local saves always ask while remote metadata saves continue in app`() { val localSong = localSong() val remoteSong = localSong.copy(mediaUri = null, localFilePath = null, channelId = "netease") - assertFalse( - shouldConfirmLocalMetadataWriteBack( - song = localSong, - title = localSong.name, - artist = localSong.artist, - coverUrl = localSong.coverUrl.orEmpty() - ) - ) - assertFalse( - shouldConfirmLocalMetadataWriteBack( - song = remoteSong, - title = "Updated title", - artist = remoteSong.artist, - coverUrl = remoteSong.coverUrl.orEmpty() - ) - ) + assertTrue(shouldConfirmLocalMetadataWriteBack(localSong)) + assertFalse(shouldConfirmLocalMetadataWriteBack(remoteSong)) } private fun localSong(): SongItem { diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingCoverOwnerTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingCoverOwnerTest.kt index 06e5e2cd0a..fa56d3f686 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingCoverOwnerTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingCoverOwnerTest.kt @@ -34,6 +34,53 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class NowPlayingCoverOwnerTest { + @Test + fun `restored known empty artwork source clears the playing songs decoded cover`() = runTest { + val key = "cover-owner-explicit-local-restore" + val edited = previewSong("__local_files__").copy( + mediaUri = "content://cover-owner-local-restore/audio/7", + coverUrl = null, + customCoverUrl = "content://cover-owner-local-restore/edited", + originalCoverUrl = "" + ) + val owner = NowPlayingCoverOwner(this) + try { + val original = requireNotNull(owner.newRequest(edited.customCoverUrl, key, "edited")) + owner.presentation(original, key, emptyList()) + val decoded = original.frame.copy(decodedBitmap = markerBitmap) + owner.publishDecodedFrame(original, decoded) + assertEquals(decoded, owner.presentation(original, key, emptyList()).visibleFrame) + + val pending = owner.newRequest(null, key, "loading") + assertEquals(decoded, owner.presentation(pending, key, emptyList()).visibleFrame) + + val source = buildNowPlayingCoverSource( + coverUrl = "content://cover-owner-local-restore/stale-parent", + songKey = key, + songKeyAliases = emptyList(), + downloadPresenceVersion = 0, + assetRootGeneration = 0, + assetSongRevision = 1, + song = edited.copy(customCoverUrl = null) + ) + assertTrue(source.clearCover) + assertNull(source.coverUrl) + val request = owner.requestFor( + NowPlayingCoverRequestInput(source.coverUrl, source.songKey, source.cacheKey, source.clearCover) + ) + assertNull(owner.presentation(request, source.songKey, source.songKeyAliases).visibleFrame) + owner.publishDecodedFrame(original, decoded) + assertNull(owner.presentation(request, source.songKey, source.songKeyAliases).visibleFrame) + + val replacement = requireNotNull(owner.requestFor( + NowPlayingCoverRequestInput("content://cover-owner-local-restore/new", key, "new") + )) + assertEquals(replacement.frame, owner.presentation(replacement, key, emptyList()).visibleFrame) + } finally { + owner.close() + } + } + @Test fun `new cover surface starts with its requested song while mounted surfaces keep the handoff`() = runTest { val owner = NowPlayingCoverOwner(this) @@ -266,6 +313,22 @@ class NowPlayingCoverOwnerTest { assertEquals("", buildNowPlayingCoverSource(null, null, emptyList(), 0, 0L, 0L, null).contentDescription) } + @Test + fun `original empty artwork does not hide a current or custom local cover`() { + val song = previewSong("__local_files__").copy( + mediaUri = "content://cover-owner-local-current/audio/7", + originalCoverUrl = "" + ) + listOf(song, song.copy(coverUrl = null, customCoverUrl = "content://cover-owner-local-current/custom")) + .forEach { current -> + val source = buildNowPlayingCoverSource( + "content://cover-owner-local-current/visible", "current", emptyList(), 0, 0, 0, current + ) + assertFalse(source.clearCover) + assertEquals("content://cover-owner-local-current/visible", source.coverUrl) + } + } + @Test fun `preload is required only when the visible frame has a different source`() { val request = buildNowPlayingCoverRequest("content://cover-owner-test/new", "song", "g2")!! diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreenTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreenTest.kt index 58e1f75250..d695bc40dc 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreenTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreenTest.kt @@ -136,7 +136,6 @@ class NowPlayingScreenTest { albumId = 0L, durationMs = 5_000L, coverUrl = " ", - originalCoverUrl = "", mediaUri = "content://media/external/audio/media/42" ) @@ -144,6 +143,10 @@ class NowPlayingScreenTest { "content://covers/42", resolveEditSongInitialCoverUrl(song, "content://covers/42") ) + assertEquals( + "", + resolveEditSongInitialCoverUrl(song.copy(originalCoverUrl = ""), "content://covers/42") + ) } @Test @@ -255,11 +258,7 @@ class NowPlayingScreenTest { assertTrue( shouldConfirmLocalMetadataWriteBack( - song = localSong, - title = localSong.name, - artist = localSong.artist, - coverUrl = localSong.coverUrl.orEmpty(), - hasPendingLyricsChange = true + song = localSong ) ) assertFalse( @@ -268,11 +267,7 @@ class NowPlayingScreenTest { album = "Netease", albumId = 1L, mediaUri = null - ), - title = localSong.name, - artist = localSong.artist, - coverUrl = localSong.coverUrl.orEmpty(), - hasPendingLyricsChange = true + ) ) ) } diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditOwnerTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditOwnerTest.kt index e2913d4a8a..51fcef7618 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditOwnerTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditOwnerTest.kt @@ -24,6 +24,7 @@ import moe.ouom.neriplayer.ui.screen.nowplaying.edit.SongEditLyricsWrite import moe.ouom.neriplayer.ui.screen.nowplaying.edit.latestMatchingEditSong import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveEditSongLyricsForSave import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveEditSongLyricsSavePlan +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveEditSongInitialCoverUrl import moe.ouom.neriplayer.ui.screen.nowplaying.edit.withVerifiedEditCoverPickerTarget import moe.ouom.neriplayer.ui.viewmodel.NowPlayingViewModel import org.junit.Assert.assertEquals @@ -307,6 +308,145 @@ class NowPlayingSongEditOwnerTest { assertEquals(1, appOnlySaves) } + @Test + fun `reopened app-only local lyrics are included when metadata writeback is chosen`() = runTest { + listOf("[00:01.00]App lyric", "").forEach { lyric -> + val edited = song.copy( + mediaUri = "content://media/external/audio/media/8", + matchedLyric = lyric, + lyricSyncEdited = true + ) + var writesLyrics: Boolean? = null + var saves = 0 + val viewModel = Mockito.mock(NowPlayingViewModel::class.java) { invocation -> + if (invocation.method.name == "updateSongInfo") { + writesLyrics = invocation.getArgument(9) + true + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + } + val owner = owner(initialSong = edited) + + owner.saveEditedSongInfo( + song = edited, + viewModel = viewModel, + writeLocalMetadata = true, + snackbarHostState = SnackbarHostState(), + resources = Mockito.mock(Resources::class.java), + onSaved = { saves++ } + ) + + assertEquals(1, saves) + assertEquals(true, writesLyrics) + owner.dispose() + } + } + + @Test + fun `reopened restored app-only metadata still asks for the file destination`() { + val restored = song.copy( + coverUrl = "current-cover", + mediaUri = "content://media/external/audio/media/8", + matchedLyric = "Original lyric", + originalLyric = "Original lyric", + lyricSyncEdited = false + ) + val owner = owner(initialSong = restored) + var confirmations = 0 + var appOnlySaves = 0 + + owner.requestSave(restored, { confirmations++ }, { appOnlySaves++ }) + + assertEquals(1, confirmations) + assertEquals(0, appOnlySaves) + } + + @Test + fun `reopened known original lyrics are written even after app-only restoration cleared overrides`() { + val local = song.copy(mediaUri = "content://media/external/audio/media/8", lyricSyncEdited = false) + val restoredSongs = listOf( + local.copy(matchedLyric = "Original lyric", originalLyric = "Original lyric"), + local.copy(matchedTranslatedLyric = "Original translation", originalTranslatedLyric = "Original translation"), + local.copy(matchedRomanizedLyric = "Original romanization", originalRomanizedLyric = "Original romanization"), + local.copy(matchedLyric = "", originalLyric = ""), + local.copy(originalLyric = "Original lyric"), + local.copy(originalTranslatedLyric = ""), + local.copy(originalRomanizedLyric = "Original romanization") + ) + + restoredSongs.forEach { restored -> + assertEquals("known original lyrics must be written: $restored", true, metadataLyricsFlag(restored)) + } + assertEquals(false, metadataLyricsFlag(local)) + } + + @Test + fun `pending lyric draft embeds once before metadata saves without repeating the lyric write`() { + val local = song.copy( + mediaUri = "content://media/external/audio/media/8", + matchedLyric = "Old app lyric", + lyricSyncEdited = true + ) + + assertEquals( + false, + metadataLyricsFlag(local, EditSongLyricsDraft("New lyric", "", "", true)) + ) + } + + @Test + fun `reopened known empty cover ignores still embedded cover from an app-only restore`() { + val restored = song.copy( + mediaUri = "content://media/external/audio/media/8", + coverUrl = null, + customCoverUrl = null, + originalCoverUrl = "" + ) + val staleEmbeddedCover = "file:///still-embedded-edited-cover.jpg" + val owner = NowPlayingSongEditOwner( + initialSong = restored, + initialCoverUrl = resolveEditSongInitialCoverUrl(restored, staleEmbeddedCover), + initialBaseline = baseline.copy(coverUrl = ""), + scope = CoroutineScope(Dispatchers.Unconfined), + onSavingChanged = {}, + playbackPort = RecordingPlaybackPort() + ) + + assertEquals("", owner.coverUrlState.value) + owner.syncUneditedSong(restored, staleEmbeddedCover) + owner.applyResolvedCover(restored, staleEmbeddedCover) + assertEquals("", owner.coverUrlState.value) + assertEquals("", owner.editBaselineState.value.coverUrl) + + val currentCover = restored.copy(coverUrl = "file:///current-cover.jpg") + assertEquals(currentCover.coverUrl, resolveEditSongInitialCoverUrl(currentCover, staleEmbeddedCover)) + val customCover = restored.copy(customCoverUrl = "file:///custom-cover.jpg") + assertEquals(customCover.customCoverUrl, resolveEditSongInitialCoverUrl(customCover, staleEmbeddedCover)) + val remote = restored.copy(mediaUri = null, channelId = "netease") + assertEquals(staleEmbeddedCover, resolveEditSongInitialCoverUrl(remote, staleEmbeddedCover)) + owner.dispose() + } + + @Test + fun `save destination reads lyrics saved before the playback snapshot recomposes`() { + val local = song.copy( + mediaUri = "content://media/external/audio/media/8", + coverUrl = "current-cover" + ) + val port = RecordingPlaybackPort().apply { + current = local.copy(matchedLyric = "App lyric", lyricSyncEdited = true) + } + val owner = owner(port, initialSong = local) + var confirmations = 0 + var appOnlySaves = 0 + + owner.requestSave(local, { confirmations++ }, { appOnlySaves++ }) + + assertEquals(1, confirmations) + assertEquals(0, appOnlySaves) + } + @Test fun `unedited song sync follows new metadata while manual edits remain stable`() { val owner = owner() @@ -320,6 +460,51 @@ class NowPlayingSongEditOwnerTest { assertEquals("Updated artist", owner.artistNameState.value) } + @Test + fun `same song refresh does not unlock restoration or replace fields during save`() { + val owner = owner() + val updated = song.copy(customName = "Background update") + owner.isOriginalInfoRestoringState.value = true + + owner.syncUneditedSong(updated, "content://cover/updated") + + assertTrue(owner.isOriginalInfoRestoringState.value) + assertEquals("Current", owner.songNameState.value) + + owner.isOriginalInfoRestoringState.value = false + owner.setSaving(true) + owner.syncUneditedSong(updated, "content://cover/updated") + + assertEquals("Current", owner.songNameState.value) + owner.setSaving(false) + } + + @Test + fun `resolved edited cover never replaces an explicitly empty original cover`() { + val edited = song.copy( + mediaUri = "content://media/external/audio/media/8", + customCoverUrl = "file:///edited-cover.jpg", + originalCoverUrl = "" + ) + val owner = owner( + initialSong = edited, + initialBaseline = baseline.copy(coverUrl = "") + ) + + owner.applyResolvedCover(edited, edited.customCoverUrl) + val request = owner.nextOriginalInfoRequest() + owner.applyRestoredOriginalInfo( + request, + EditSongRestoreSelection(cover = true, title = false, artist = false, lyrics = false), + owner.editBaselineState.value, + null + ) + + assertEquals("", owner.editBaselineState.value.coverUrl) + assertEquals("", owner.coverUrlState.value) + assertTrue(owner.shouldRestoreCoverBaseState.value) + } + @Test fun `managed baseline and resolved cover stop replacing manual edits`() { val owner = NowPlayingSongEditOwner( @@ -511,11 +696,47 @@ class NowPlayingSongEditOwnerTest { assertEquals(setOf("restore", "lyrics", "cover", "save"), cancelled) } - private fun owner(playbackPort: RecordingPlaybackPort = RecordingPlaybackPort()) = + private fun metadataLyricsFlag(song: SongItem, pendingDraft: EditSongLyricsDraft? = null): Boolean? { + var writesLyrics: Boolean? = null + var saves = 0 + val viewModel = Mockito.mock(NowPlayingViewModel::class.java) { invocation -> + if (invocation.method.name == "updateSongInfo") { + writesLyrics = invocation.getArgument(9) + true + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + } + val playback = RecordingPlaybackPort() + val owner = owner(playback, initialSong = song) + owner.pendingLyricsDraftState.value = pendingDraft + owner.saveEditedSongInfo( + song = song, + viewModel = viewModel, + writeLocalMetadata = true, + snackbarHostState = SnackbarHostState(), + resources = Mockito.mock(Resources::class.java), + onSaved = { saves++ } + ) + assertEquals(1, saves) + if (pendingDraft == null) { + assertTrue(playback.writes.isEmpty()) + } else { + assertTrue(playback.writes.single().writeLocalMetadata) + } + owner.dispose() + return writesLyrics + } + + private fun owner( + playbackPort: RecordingPlaybackPort = RecordingPlaybackPort(), + initialSong: SongItem = song, + initialBaseline: EditSongBaseline = baseline + ) = NowPlayingSongEditOwner( - initialSong = song, + initialSong = initialSong, initialCoverUrl = "current-cover", - initialBaseline = baseline, + initialBaseline = initialBaseline, scope = CoroutineScope(Dispatchers.Unconfined), onSavingChanged = {}, playbackPort = playbackPort diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditPolicyTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditPolicyTest.kt index 89388d9c04..f610480499 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditPolicyTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditPolicyTest.kt @@ -7,6 +7,8 @@ import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata import moe.ouom.neriplayer.data.model.lyrics.LyricsEditorSeed import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongMetadataSnapshot import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongBaseline +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongLyricsDraft +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongLyricsSavePlan import moe.ouom.neriplayer.ui.screen.nowplaying.edit.canChooseEmbeddedLyricsSource import moe.ouom.neriplayer.ui.screen.nowplaying.edit.editSongCoverForSave import moe.ouom.neriplayer.ui.screen.nowplaying.edit.editSongLyricsLoadErrorMessage @@ -28,6 +30,7 @@ import moe.ouom.neriplayer.ui.screen.nowplaying.edit.shouldHandleEditLyricsPermi import moe.ouom.neriplayer.ui.screen.nowplaying.edit.shouldOfferFilledLyricsWriteBack import moe.ouom.neriplayer.ui.screen.nowplaying.edit.shouldOpenEditLyricsSeedImmediately import moe.ouom.neriplayer.ui.screen.nowplaying.edit.shouldPersistEditSongManualCover +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.shouldWriteEditSongLyrics import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -35,6 +38,49 @@ import org.junit.Assert.assertTrue import org.junit.Test class NowPlayingSongEditPolicyTest { + @Test + fun `explicit metadata write includes every known lyric track including cleared originals`() { + val song = SongItem( + 8L, "Title", "Artist", "Album", 2L, 60_000L, null, + mediaUri = "content://media/external/audio/media/8" + ) + val plan = EditSongLyricsSavePlan(null, false, false, false) + listOf("Known lyric", "").forEach { text -> + val knownTracks = listOf( + song.copy(matchedLyric = text), + song.copy(matchedTranslatedLyric = text), + song.copy(matchedRomanizedLyric = text), + song.copy(originalLyric = text), + song.copy(originalTranslatedLyric = text), + song.copy(originalRomanizedLyric = text) + ) + knownTracks.forEach { known -> + assertTrue(shouldWriteEditSongLyrics(known, plan, writeLocalMetadata = true)) + } + } + assertFalse(shouldWriteEditSongLyrics(song, plan, writeLocalMetadata = true)) + } + + @Test + fun `metadata lyric plan keeps app-only remote and already embedded drafts separate`() { + val song = SongItem( + 8L, "Title", "Artist", "Album", 2L, 60_000L, null, + mediaUri = "content://media/external/audio/media/8", + matchedLyric = "Known lyric" + ) + val plan = EditSongLyricsSavePlan(null, false, false, false) + val draft = EditSongLyricsDraft("New lyric", "", "", true) + + assertFalse(shouldWriteEditSongLyrics(song, plan, writeLocalMetadata = false)) + assertFalse(shouldWriteEditSongLyrics(song.copy(mediaUri = null), plan, writeLocalMetadata = true)) + assertFalse(shouldWriteEditSongLyrics(song, plan.copy(draft = draft), writeLocalMetadata = true)) + assertTrue(shouldWriteEditSongLyrics( + song, + plan.copy(draft = draft, writeLyricsWithSongMetadata = true), + writeLocalMetadata = true + )) + } + @Test fun `baseline cover only adopts a nonblank initial reference for missing or remote covers`() { assertNull(refreshedEditSongBaselineCover("content://cover/local", "content://cover/new")) @@ -307,6 +353,21 @@ class NowPlayingSongEditPolicyTest { ) } + @Test + fun `explicitly absent managed baseline cover wins over stale resolved assets`() { + val current = EditSongBaseline("Title", "Artist", "content://cover/current", null, null, null) + val metadata = ManagedDownloadRestorableMetadata( + sourceStableKey = "stable", + baseline = ManagedDownloadRestorableMetadata.Baseline(coverReference = ""), + overrides = ManagedDownloadRestorableMetadata.Overrides() + ) + + assertEquals( + "", + resolveManagedEditSongBaseline(current, metadata, "content://cover/stale-asset").coverUrl + ) + } + @Test fun `managed baseline preserves local file and remote cover references without metadata`() { val current = EditSongBaseline("Title", "Artist", "file:/covers/current.jpg", null, null, null) diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/nowplaying/MoreOptionsSheetBackTargetTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/nowplaying/MoreOptionsSheetBackTargetTest.kt index 9b382e76e7..c7dbd5975e 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/nowplaying/MoreOptionsSheetBackTargetTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/nowplaying/MoreOptionsSheetBackTargetTest.kt @@ -84,6 +84,26 @@ class MoreOptionsSheetBackTargetTest { assertFalse(owner.canPredictiveDismiss) } + @Test + fun `metadata application keeps the search visible until completion`() { + val owner = owner() + owner.open(MoreOptionsPage.SEARCH) + owner.setMetadataApplying(true) + + owner.back() + owner.dismiss() + + assertEquals(MoreOptionsPage.SEARCH, owner.page) + assertFalse(owner.isDismissing) + assertFalse(owner.canGoBackToMainPage) + assertFalse(owner.canPredictiveDismiss) + assertFalse(owner.sheetGesturesEnabled) + + owner.setMetadataApplying(false) + assertTrue(owner.canGoBackToMainPage) + assertTrue(owner.sheetGesturesEnabled) + } + @Test fun `an in flight dismissal owns back until the sheet is hidden`() { val scope = CoroutineScope(Dispatchers.Unconfined) diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/util/CoverUrlStateComposeTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/util/CoverUrlStateComposeTest.kt new file mode 100644 index 0000000000..463a3a4c8b --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/util/CoverUrlStateComposeTest.kt @@ -0,0 +1,121 @@ +package moe.ouom.neriplayer.ui.util + +import android.content.Context +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.Mockito + +class CoverUrlStateComposeTest { + @Test + fun `restoring known empty local artwork clears the mounted display resolver immediately`() = runTest { + val edited = SongItem( + id = 74_123L, + name = "Restore cover", + artist = "Artist", + album = "Album", + albumId = 0L, + durationMs = 60_000L, + coverUrl = null, + mediaUri = "content://cover-reset-test/audio/74123", + customCoverUrl = "file:///cover-reset-test/edited.jpg", + originalCoverUrl = "", + channelId = "local", + audioId = "74123" + ) + val fixture = CoverCompositionFixture(this) + try { + assertEquals(edited.customCoverUrl, fixture.compose(edited)) + + val loading = edited.copy(customCoverUrl = null, originalCoverUrl = null) + assertEquals(edited.customCoverUrl, fixture.compose(loading)) + + val restored = loading.copy(originalCoverUrl = "") + assertNull(fixture.compose(restored)) + } finally { + fixture.close() + } + } + + @Test + fun `restoring blank local artwork fields clears the mounted display resolver`() = runTest { + val edited = SongItem( + id = 74_124L, + name = "Restore blank cover", + artist = "Artist", + album = "Album", + albumId = 0L, + durationMs = 60_000L, + coverUrl = null, + mediaUri = "content://cover-reset-test/audio/74124", + customCoverUrl = "file:///cover-reset-test/edited-blank.jpg", + originalCoverUrl = "", + channelId = "local", + audioId = "74124" + ) + val blankArtworkFields = listOf( + "" to null, + null to "", + "" to "", + " \t " to null, + null to " \t " + ) + for ((coverUrl, customCoverUrl) in blankArtworkFields) { + val fixture = CoverCompositionFixture(this) + try { + assertEquals(edited.customCoverUrl, fixture.compose(edited)) + + val restored = edited.copy(coverUrl = coverUrl, customCoverUrl = customCoverUrl) + assertNull("coverUrl=$coverUrl customCoverUrl=$customCoverUrl", fixture.compose(restored)) + } finally { + fixture.close() + } + } + } + + private class CoverCompositionFixture(scope: CoroutineScope) { + private val recomposer = Recomposer(scope.coroutineContext) + private val composition = Composition(EmptyApplier(), recomposer) + private val context = Mockito.mock(Context::class.java).also { + Mockito.`when`(it.applicationContext).thenReturn(it) + } + + fun compose(song: SongItem): String? { + var result: String? = null + composition.setContent { + CompositionLocalProvider(LocalContext provides context) { + result = rememberSongDisplayCoverUrl( + song = song, + resolveLocalFallback = false, + downloadPresenceVersion = 0, + allowEmbeddedCoverFallback = false, + localAssetGeneration = "cover-reset-composition" + ) + } + } + return result + } + + suspend fun close() { + composition.dispose() + recomposer.cancel() + recomposer.join() + } + } + + private class EmptyApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit + } +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwnerTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwnerTest.kt new file mode 100644 index 0000000000..c66ee1ceb5 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/ManualMetadataApplicationOwnerTest.kt @@ -0,0 +1,101 @@ +package moe.ouom.neriplayer.ui.viewmodel + +import kotlinx.coroutines.Job +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManualMetadataApplicationOwnerTest { + @Test + fun `cancelling preparation rejects late completion and progress`() { + val owner = ManualMetadataApplicationOwner() + val job = Job() + var progress: (SongMetadataApplyStage) -> Unit = {} + var complete: (Boolean) -> Unit = {} + var applied = 0 + owner.start(onComplete = { if (it) applied++ }) { onProgress, onComplete -> + progress = onProgress + complete = onComplete + job + } + + assertTrue(owner.cancel()) + progress(SongMetadataApplyStage.APPLYING) + complete(true) + + assertTrue(job.isCancelled) + assertFalse(owner.state.value.isRunning) + assertFalse(owner.state.value.completed) + assertFalse(owner.state.value.failed) + assertEquals(0, applied) + } + + @Test + fun `committing cannot be cancelled and success becomes visible before dismissal`() { + val owner = ManualMetadataApplicationOwner() + val job = Job() + var complete: (Boolean) -> Unit = {} + owner.start { onProgress, onComplete -> + onProgress(SongMetadataApplyStage.APPLYING) + complete = onComplete + job + } + + assertFalse(owner.cancel()) + assertFalse(job.isCancelled) + assertTrue(owner.state.value.isRunning) + complete(true) + + assertTrue(owner.state.value.completed) + assertFalse(owner.state.value.failed) + assertFalse(owner.state.value.isRunning) + } + + @Test + fun `failure keeps the operation available for retry`() { + val owner = ManualMetadataApplicationOwner() + var complete: (Boolean) -> Unit = {} + owner.start { _, onComplete -> complete = onComplete; Job() } + complete(false) + + assertTrue(owner.state.value.failed) + assertFalse(owner.state.value.completed) + assertTrue(owner.start { _, _ -> Job() }) + assertTrue(owner.state.value.isRunning) + assertFalse(owner.state.value.failed) + } + + @Test + fun `cancelled request cannot finish a newer application`() { + val owner = ManualMetadataApplicationOwner() + var staleComplete: (Boolean) -> Unit = {} + owner.start { _, onComplete -> staleComplete = onComplete; Job() } + owner.cancel() + owner.start { onProgress, _ -> + onProgress(SongMetadataApplyStage.FETCHING_LYRICS) + Job() + } + + staleComplete(true) + + assertTrue(owner.state.value.isRunning) + assertEquals(SongMetadataApplyStage.FETCHING_LYRICS, owner.state.value.stage) + assertFalse(owner.state.value.completed) + } + + @Test + fun `duplicate selection does not launch a second application`() { + val owner = ManualMetadataApplicationOwner() + var launches = 0 + val launch: ((SongMetadataApplyStage) -> Unit, (Boolean) -> Unit) -> Job = { _, _ -> + launches++ + Job() + } + + assertTrue(owner.start(launch = launch)) + assertFalse(owner.start(launch = launch)) + assertEquals(1, launches) + } +} diff --git a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoder.kt b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoder.kt index c4d1cd8bf0..e0674fba50 100644 --- a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoder.kt +++ b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoder.kt @@ -38,7 +38,8 @@ class ManagedDownloadedAudioMetadataDecoder(private val root: JSONObject) { customArtist = nonblankString("customArtist", overrides.artist), originalName = nonblankString("originalName", baseline.title), originalArtist = nonblankString("originalArtist", baseline.artist), - originalCoverUrl = nonblankString("originalCoverUrl", baseline.coverReference), + originalCoverUrl = presentString("originalCoverUrl")?.takeIf(String::isEmpty) + ?: nonblankString("originalCoverUrl", baseline.coverReference), originalLyric = presentString("originalLyric", baseline.originalLyric), originalTranslatedLyric = presentString( "originalTranslatedLyric", baseline.translatedLyric diff --git a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataJson.kt b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataJson.kt index 7b1906b282..a5be8118ff 100644 --- a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataJson.kt +++ b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataJson.kt @@ -83,13 +83,20 @@ private fun JSONObject?.toBaseline(): ManagedDownloadRestorableMetadata.Baseline title = optionalString("title"), artist = optionalString("artist"), album = optionalString("album"), - coverReference = optionalString("coverReference"), + coverReference = originalCoverReference(), originalLyric = optionalLyric("originalLyric"), translatedLyric = optionalLyric("translatedLyric"), romanizedLyric = optionalLyric("romanizedLyric") ) } +private fun JSONObject.originalCoverReference(): String? { + if (has("coverReference") && !isNull("coverReference") && optString("coverReference").isEmpty()) { + return "" + } + return optionalString("coverReference") +} + private fun JSONObject?.toOverrides(): ManagedDownloadRestorableMetadata.Overrides { this ?: return ManagedDownloadRestorableMetadata.Overrides() return ManagedDownloadRestorableMetadata.Overrides( diff --git a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoderTest.kt b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoderTest.kt index 1bf459c868..0c754c07fd 100644 --- a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoderTest.kt +++ b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/codec/ManagedDownloadedAudioMetadataDecoderTest.kt @@ -16,6 +16,49 @@ class ManagedDownloadedAudioMetadataDecoderTest { assertEquals(DownloadedAudioMetadata(), decode(JSONObject())) } + @Test + fun `explicitly absent flat original cover does not fall back to a nested cover`() { + val root = JSONObject() + .put("originalCoverUrl", "") + .put("coverUrl", "https://example.com/current.jpg") + .put("restorableMetadata", restorable().toJson()) + + assertEquals("", decode(root).originalCoverUrl) + assertEquals("https://example.com/current.jpg", decode(root).coverUrl) + } + + @Test + fun `original cover keeps explicit absence while other blank values fall back to baseline`() { + val cases = listOf( + null to "baseline-cover", + JSONObject.NULL to "baseline-cover", + "" to "", + " \t " to "baseline-cover", + "null" to "null", + " padded " to " padded ", + 42 to "42", + true to "true" + ) + cases.forEach { (raw, expected) -> + val root = JSONObject() + .put("originalCoverUrl", raw) + .put("restorableMetadata", restorable().toJson()) + assertEquals("originalCoverUrl: $raw", expected, decode(root).originalCoverUrl) + } + } + + @Test + fun `explicitly absent nested original cover survives metadata decoding`() { + val root = JSONObject().put( + "restorableMetadata", + restorable().copy( + baseline = ManagedDownloadRestorableMetadata.Baseline(coverReference = "") + ).toJson() + ) + + assertEquals("", decode(root).originalCoverUrl) + } + @Test fun `nonblank fields preserve whitespace literal null and JSON coercion`() { val cases = listOf( @@ -143,7 +186,7 @@ class ManagedDownloadedAudioMetadataDecoderTest { val root = JSONObject().put("restorableMetadata", nested.toJson()) listOf( "stableKey", "name", "artist", "customName", "customArtist", - "originalName", "originalArtist", "originalCoverUrl", "coverPath" + "originalName", "originalArtist", "coverPath" ).forEach { root.put(it, raw) } val metadata = decode(root) @@ -404,7 +447,6 @@ class ManagedDownloadedAudioMetadataDecoderTest { MetadataField("customArtist") { it.customArtist }, MetadataField("originalName") { it.originalName }, MetadataField("originalArtist") { it.originalArtist }, - MetadataField("originalCoverUrl") { it.originalCoverUrl }, MetadataField("mediaUri") { it.mediaUri }, MetadataField("channelId") { it.channelId }, MetadataField("audioId") { it.audioId }, diff --git a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataTest.kt b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataTest.kt index e0231c8905..0964c005a3 100644 --- a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataTest.kt +++ b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/metadata/serialization/ManagedDownloadRestorableMetadataTest.kt @@ -139,6 +139,20 @@ class ManagedDownloadRestorableMetadataTest { assertNull(restored?.overrides?.coverReference) } + @Test + fun `explicitly absent baseline cover survives round trip without a current cover override`() { + val restored = ManagedDownloadRestorableMetadata.fromJson( + ManagedDownloadRestorableMetadata( + sourceStableKey = "stable", + baseline = ManagedDownloadRestorableMetadata.Baseline(coverReference = ""), + overrides = ManagedDownloadRestorableMetadata.Overrides(coverReference = "") + ).toJson() + ) + + assertEquals("", restored?.baseline?.coverReference) + assertNull(restored?.overrides?.coverReference) + } + @Test fun `baseline empty lyrics survive round trip as explicit absence`() { val restored = ManagedDownloadRestorableMetadata.fromJson( diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilder.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilder.kt index 736e92df2e..dc4f30aa28 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilder.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilder.kt @@ -135,7 +135,9 @@ internal class DownloadedSongBuilder( ), coverUrl = sanitizeDownloadedCoverMetadataReference(values.coverUrl, snapshot), customCoverUrl = sanitizeDownloadedCoverMetadataReference(values.customCoverUrl, snapshot), - originalCoverUrl = sanitizeDownloadedCoverMetadataReference(values.originalCoverUrl, snapshot) + originalCoverUrl = if (values.originalCoverUrl == "") "" else { + sanitizeDownloadedCoverMetadataReference(values.originalCoverUrl, snapshot) + } ) } diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogModels.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogModels.kt index 467450b8c1..8f3d8865d2 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogModels.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogModels.kt @@ -610,7 +610,8 @@ internal fun deserializeDownloadedSongsCatalog( customArtist = item.optString("customArtist").takeIf(String::isNotBlank), originalName = item.optString("originalName").takeIf(String::isNotBlank), originalArtist = item.optString("originalArtist").takeIf(String::isNotBlank), - originalCoverUrl = item.optString("originalCoverUrl").takeIf(String::isNotBlank), + originalCoverUrl = item.optPresentCatalogString("originalCoverUrl") + ?.takeIf { it.isEmpty() || it.isNotBlank() }, originalLyric = if (includeOriginalLyrics) { item.optPresentCatalogString("originalLyric") } else { diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/policy/GlobalDownloadPolicies.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/policy/GlobalDownloadPolicies.kt index d98ada7931..fa1c08940d 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/policy/GlobalDownloadPolicies.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/policy/GlobalDownloadPolicies.kt @@ -697,6 +697,7 @@ internal suspend fun resolveRestorableCoverReference( findManagedReferenceByName: suspend (String) -> String?, findContentAddressedReference: suspend (String) -> String? ): String? { + if (baseline && metadata.baseline.coverReference == "") return null val directReference = if (baseline) { metadata.baseline.coverReference } else { diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/GlobalDownloadManagerStartupPolicyTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/GlobalDownloadManagerStartupPolicyTest.kt index 4e3c6f9d6b..fae4825988 100644 --- a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/GlobalDownloadManagerStartupPolicyTest.kt +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/GlobalDownloadManagerStartupPolicyTest.kt @@ -704,6 +704,34 @@ class GlobalDownloadManagerStartupPolicyTest : GlobalDownloadManagerStartupPolic assertFalse(legacyLookupCalled) } + @Test + fun `explicitly absent baseline cover skips leftover cover asset recovery`() = runBlocking { + val assetHash = "a".repeat(64) + val staleReference = "content://downloads/Covers/stale-original.jpg" + var assetReads = 0 + val metadata = ManagedDownloadRestorableMetadata( + sourceStableKey = "1|netease|", + baseline = ManagedDownloadRestorableMetadata.Baseline(coverReference = ""), + overrides = ManagedDownloadRestorableMetadata.Overrides(), + baselineCoverAssetHash = assetHash, + baselineCoverAssetFileName = "stale-original.jpg" + ) + + val resolved = resolveRestorableCoverReference( + metadata = metadata, + baseline = true, + fingerprintReference = { reference -> + assetReads++ + ManagedDownloadCoverAssetStore.MaterializedCover(reference, assetHash) + }, + findManagedReferenceByName = { assetReads++; staleReference }, + findContentAddressedReference = { assetReads++; staleReference } + ) + + assertNull(resolved) + assertEquals(0, assetReads) + } + @Test fun `restorable cover retains legacy pure sha lookup fallback`() = runBlocking { val assetHash = "b".repeat(64) diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderAssemblyTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderAssemblyTest.kt index 082067c0c1..7a98460a61 100644 --- a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderAssemblyTest.kt +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderAssemblyTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.test.runTest import moe.ouom.neriplayer.common.R as CoreCommonR import moe.ouom.neriplayer.core.download.ManagedDownloadStorage import moe.ouom.neriplayer.core.download.metadata.DownloadedAudioMetadataStore +import moe.ouom.neriplayer.core.download.policy.toPlaybackSongItem import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -15,6 +16,33 @@ import org.mockito.Mockito.`when` import moe.ouom.neriplayer.data.model.download.DownloadedAudioMetadata class DownloadedSongBuilderAssemblyTest { + @Test + fun `snapshot hydration keeps an explicitly absent original cover through playback projection`() = runTest { + val context = mock(Context::class.java) + val store = mock(DownloadedAudioMetadataStore::class.java) + val audio = entry("Artist - Song.flac", "/downloads/Artist - Song.flac") + val metadata = DownloadedAudioMetadata( + songId = 123L, + name = "Song", + artist = "Artist", + album = "Album", + coverUrl = "https://example.com/current.jpg", + customCoverUrl = "https://example.com/current.jpg", + originalCoverUrl = "" + ) + val snapshot = emptySnapshot(audio).copy(metadataByAudioName = mapOf(audio.name to metadata)) + + val song = DownloadedSongBuilder(store, "test").build( + context, audio, snapshot, allowSlowLocalInspection = false, + verifySnapshotReferences = false + ) + + assertEquals("", song.originalCoverUrl) + assertEquals("", song.toPlaybackSongItem().originalCoverUrl) + assertEquals(metadata.customCoverUrl, song.customCoverUrl) + verifyNoInteractions(context, store) + } + @Test fun `missing snapshot metadata reads the sidecar and retains its logical download time`() = runTest { val context = mock(Context::class.java) diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderCoverPolicyTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderCoverPolicyTest.kt index a59761a22f..53e0ad5726 100644 --- a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderCoverPolicyTest.kt +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongBuilderCoverPolicyTest.kt @@ -230,6 +230,9 @@ class DownloadedSongBuilderCoverPolicyTest { val currentReference = "content://downloads-new/Covers/song.jpg" val snapshot = emptySnapshot(knownReferences = setOf(currentReference)) + assertNull(sanitizeDownloadedCoverMetadataReference("", snapshot)) + assertNull(sanitizeDownloadedCoverMetadataReference(" \t ", snapshot)) + assertNull( sanitizeDownloadedCoverMetadataReference( "content://downloads-old/Covers/song.jpg", diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogProjectionTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogProjectionTest.kt index af19ce2078..e7a550797b 100644 --- a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogProjectionTest.kt +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/catalog/DownloadedSongCatalogProjectionTest.kt @@ -2,6 +2,7 @@ package moe.ouom.neriplayer.core.download.catalog import moe.ouom.neriplayer.data.identity.identity import moe.ouom.neriplayer.data.identity.stableKey +import moe.ouom.neriplayer.core.download.policy.toPlaybackSongItem import moe.ouom.neriplayer.data.model.download.DownloadedSong import moe.ouom.neriplayer.data.model.SongItem @@ -14,6 +15,23 @@ import org.junit.Test class DownloadedSongCatalogProjectionTest { + @Test + fun `legacy catalog round trip preserves an explicitly absent original cover for playback`() { + val song = downloadedSong( + coverPath = "content://downloads/Covers/custom.jpg", + customCoverUrl = "file:///data/user/0/app/custom.jpg" + ).copy(originalCoverUrl = "") + + val restored = deserializeDownloadedSongsCatalog( + serializeDownloadedSongsCatalog("root", listOf(song)), + expectedCacheKey = "root" + )?.single() + + assertEquals("", restored?.originalCoverUrl) + assertEquals("", restored?.toPlaybackSongItem()?.originalCoverUrl) + assertEquals(song.customCoverUrl, restored?.customCoverUrl) + } + @Test fun `restoring a remote original cover clears the stale downloaded sidecar`() { val originalCover = "https://example.com/original.jpg" diff --git a/modules/local/README.md b/modules/local/README.md index 8c3edd0223..c70afdc8fe 100644 --- a/modules/local/README.md +++ b/modules/local/README.md @@ -52,16 +52,20 @@ Local import cache cleanup removes extracted covers with no remaining references | 项目 | 规则 | | --- | --- | -| 原始基线与恢复 | 本地编辑保留首次修改前的歌词与封面,恢复时优先采用持久化的原始值 | +| 原始基线与恢复 | 本地编辑保留首次修改前的歌词与封面,恢复时优先采用持久化的原始值;明确无原封面的状态在重扫和重开后仍会保留 | | 重扫隔离 | 重新扫描不会用当前侧载内容覆盖已保存的原始值 | | 空值与文件复用 | 区分未知内容与明确为空;缺失且为空的翻译、罗马音歌词不创建新文件,已有歌词文件按类型复用 | | Aspect | Rule | | --- | --- | -| Original Baseline & Restore | Local edits preserve lyrics and artwork from before the first edit; restoration prefers persisted originals | +| Original Baseline & Restore | Local edits preserve lyrics and artwork from before the first edit; restoration prefers persisted originals and retains a confirmed absence of original artwork across rescans and reopening | | Rescan Isolation | Rescanning never overwrites persisted originals with current sidecar contents | | Empty Values & File Reuse | Unknown and explicitly empty values remain distinct; missing empty translation or romanization tracks do not create extra files, and existing lyric files are reused by type | +本地歌曲每次在编辑页保存时都可选择回写音频或仅应用内保存。显式回写会处理已有的自定义值和已知的恢复值,即使表单内容没有再次改变;未知内容继续保留。 + +Every local song save offers audio write-back or app-only persistence. Explicit write-back includes existing custom values and known restored values even when the form has not changed again; unknown content remains preserved. + ## 写入授权与恢复 / Write authorization and recovery `LocalMediaWriteAccess` 在写入前规划存储授权;音频及歌词、封面伴随文件的修改通过恢复日志管理: diff --git a/modules/local/src/androidTest/assets/metadata/README.md b/modules/local/src/androidTest/assets/metadata/README.md new file mode 100644 index 0000000000..5179f5b91e --- /dev/null +++ b/modules/local/src/androidTest/assets/metadata/README.md @@ -0,0 +1,7 @@ +The audio fixture is synthetic noise generated for local metadata round-trip tests. It contains no recorded or third-party audio and has no artwork + +Generation command: + +```sh +ffmpeg -hide_banner -loglevel error -f lavfi -i 'anoisesrc=sample_rate=96000:amplitude=0.1:duration=0.25:seed=42' -ac 2 -c:a flac -sample_fmt s32 -metadata title=Original -metadata artist=Artist -metadata album=Fixture -metadata lyrics='[00:00.00]original' synthetic-edit.flac +``` diff --git a/modules/local/src/androidTest/assets/metadata/synthetic-edit.flac b/modules/local/src/androidTest/assets/metadata/synthetic-edit.flac new file mode 100644 index 0000000000000000000000000000000000000000..c37ba6650ec00a18ed314296a275cd6386876577 GIT binary patch literal 70902 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 J00000py0p2U+e$? literal 0 HcmV?d00001 diff --git a/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMetadataEditRoundTripTest.kt b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMetadataEditRoundTripTest.kt new file mode 100644 index 0000000000..dd5d216ca8 --- /dev/null +++ b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMetadataEditRoundTripTest.kt @@ -0,0 +1,142 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.graphics.Color +import android.media.MediaExtractor +import android.os.ParcelFileDescriptor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kyant.taglib.TagLib +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.download.storage.metadata.codec.ManagedDownloadedAudioMetadataDecoder +import moe.ouom.neriplayer.data.model.SongItem +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class LocalMetadataEditRoundTripTest { + @Test + fun appOnlyWritebackAndRestoreRepeatWithoutChangingAudioFrames() = runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val base = instrumentation.targetContext + val directory = File(base.cacheDir, "metadata-roundtrip-${UUID.randomUUID()}") + assertTrue(directory.mkdirs()) + val context = object : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getNoBackupFilesDir(): File = directory + } + try { + val audio = File(directory, "song.flac") + instrumentation.context.assets.open("metadata/synthetic-edit.flac").use { input -> + audio.outputStream().use { input.copyTo(it) } + } + val originalAudio = audioFrames(audio) + val cover = File(directory, "selected.png") + val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(Color.BLUE) + cover.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + bitmap.recycle() + } + val original = SongItem( + 741L, "Original", "Artist", "Fixture", 0L, 250L, null, + mediaUri = audio.toURI().toString(), localFilePath = audio.absolutePath, + localFileName = audio.name, originalName = "Original", originalArtist = "Artist", + originalCoverUrl = "", originalLyric = "[00:00.00]original", + originalTranslatedLyric = "", originalRomanizedLyric = "", + matchedLyric = "[00:00.00]original" + ) + val sidecar = File(directory, "song.flac.npmeta.json") + sidecar.writeText("""{"restorableMetadata":{"baseline":{"title":"Original","coverReference":""},"overrides":{}}}""") + repeat(3) { round -> + val edited = original.copy( + customName = "Edited $round", customArtist = "Artist $round", + customCoverUrl = cover.toURI().toString(), + matchedLyric = "[00:00.00]edited $round", matchedTranslatedLyric = "[00:00.00]translation $round", + matchedRomanizedLyric = "[00:00.00]romanization $round", lyricSyncEdited = true + ) + val beforeAppOnly = audio.readBytes() + assertTrue(LocalMediaSupport.writeLocalMetadataSidecar( + context, edited, writeLyrics = true, coverReference = edited.customCoverUrl + )) + assertArrayEquals(beforeAppOnly, audio.readBytes()) + assertEmbedded(audio, title = "Original", hasCover = false) + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, LocalMediaSupport.writeEditableMetadata( + context, edited, coverReference = edited.customCoverUrl, writeCover = true, writeLyrics = true + )) + assertEmbedded(audio, title = "Edited $round", hasCover = true) + assertEquals(originalAudio, audioFrames(audio)) + assertEquals("[00:00.00]edited $round", decode(sidecar).matchedLyric) + assertTrue(LocalMediaSupport.writeLocalMetadataSidecar( + context, original, writeLyrics = true, clearCoverReference = true + )) + assertEmbedded(audio, title = "Edited $round", hasCover = true) + assertNull(decode(sidecar).customName) + assertEquals("", decode(sidecar).matchedTranslatedLyric) + repeat(2) { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, LocalMediaSupport.writeEditableMetadata( + context, original, coverReference = null, writeCover = true, writeLyrics = true + )) + assertEmbedded(audio, title = "Original", hasCover = false) + assertEquals(originalAudio, audioFrames(audio)) + val restored = decode(sidecar) + assertNull(restored.customName) + assertNull(restored.customArtist) + assertEquals("[00:00.00]original", restored.matchedLyric) + assertEquals("", restored.matchedTranslatedLyric) + assertEquals("", restored.matchedRomanizedLyric) + } + } + } finally { + assertTrue(directory.deleteRecursively()) + } + } + + private fun decode(sidecar: File) = ManagedDownloadedAudioMetadataDecoder(JSONObject(sidecar.readText())).decode() + + private fun assertEmbedded(audio: File, title: String, hasCover: Boolean) { + ParcelFileDescriptor.open(audio, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor -> + val metadata = requireNotNull(TagLib.getMetadata(descriptor.dup().detachFd(), false)) + assertEquals(title, metadata.propertyMap["TITLE"]?.single()) + assertEquals(hasCover, TagLib.getPictures(descriptor.dup().detachFd()).isNotEmpty()) + } + } + + private fun audioFrames(audio: File): String { + val extractor = MediaExtractor() + try { + extractor.setDataSource(audio.absolutePath) + assertTrue(extractor.trackCount > 0) + extractor.selectTrack(0) + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteBuffer.allocate(1024 * 1024) + var samples = 0 + while (true) { + buffer.clear() + val count = extractor.readSampleData(buffer, 0) + if (count < 0) break + buffer.position(0) + buffer.limit(count) + digest.update(buffer) + samples++ + if (!extractor.advance()) break + } + assertTrue(samples > 0) + return digest.digest().joinToString("") { "%02x".format(it) } + } finally { + extractor.release() + } + } +} diff --git a/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMetadataRecoveryThroughputTest.kt b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMetadataRecoveryThroughputTest.kt new file mode 100644 index 0000000000..f32124f902 --- /dev/null +++ b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMetadataRecoveryThroughputTest.kt @@ -0,0 +1,80 @@ +package moe.ouom.neriplayer.data.local.media.metadata + +import android.content.Context +import android.content.ContextWrapper +import android.os.SystemClock +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalMetadataRecoveryThroughputTest { + @Test + fun repeatedLargeReplacementsVerifyBytesIdentityAndTimestamp() { + val base = InstrumentationRegistry.getInstrumentation().targetContext + val directory = File(base.cacheDir, "metadata-throughput-${UUID.randomUUID()}") + assertTrue(directory.mkdirs()) + val context = object : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getNoBackupFilesDir(): File = directory + } + val times = mutableListOf() + try { + val target = File(directory, "owned-audio.bin") + val chunk = ByteArray(64 * 1024) { (it % 251).toByte() } + target.outputStream().use { output -> repeat(384) { output.write(chunk) } } + val originalTime = target.lastModified() + val identity = android.system.Os.stat(target.absolutePath).st_ino + repeat(4) { round -> + val staging = LocalMediaMetadataRecoveryStore.stagingDirectory(context).apply { mkdirs() } + val backup = File(staging, "backup-$round.bin") + val updated = File(staging, "updated-$round.bin") + target.copyTo(backup) + target.copyTo(updated) + RandomAccessFile(updated, "rw").use { it.seek(1024L); it.write(round + 50) } + val expected = digest(updated) + val started = SystemClock.elapsedRealtime() + val prepared = LocalMediaMetadataRecoveryStore.begin( + context, target.absolutePath, backup, updated, originalTime + ) + val replacing = LocalMediaMetadataRecoveryStore.markReplacing(prepared) + assertTrue(LocalMediaMetadataRecoveryStore.replaceTargetFromFile( + context = context, targetReference = target.absolutePath, source = updated, + recoveryRecord = replacing, lastModifiedMs = originalTime + )) + assertTrue(LocalMediaMetadataRecoveryStore.targetMatches(context, target.absolutePath, expected)) + LocalMediaMetadataRecoveryStore.complete(LocalMediaMetadataRecoveryStore.markTargetVerified(replacing)) + times += SystemClock.elapsedRealtime() - started + assertEquals(expected, digest(target)) + assertEquals(originalTime, target.lastModified()) + assertEquals(identity, android.system.Os.stat(target.absolutePath).st_ino) + assertFalse(replacing.journalFile.exists()) + } + Log.i("MetadataThroughput", "bytes=${target.length()}, elapsedMs=$times") + } finally { + assertTrue(directory.deleteRecursively()) + } + } + + private fun digest(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(16 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorage.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorage.kt index 7c57e8250f..e6cebecdb0 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorage.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorage.kt @@ -27,6 +27,10 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import moe.ouom.neriplayer.network.http.awaitResponse import moe.ouom.neriplayer.data.network.DataHttpClients import moe.ouom.neriplayer.core.download.storage.metadata.MAX_SOURCE_COVER_BYTES import moe.ouom.neriplayer.core.download.storage.metadata.isCoverPixelBudgetWithin @@ -346,27 +350,33 @@ object CustomSongCoverStorage { .header("Accept", "image/*") .build() }.getOrNull() ?: return null - val bytes = runCatching { - remoteCoverHttpClientProvider().newCall(request).execute().use { response -> - if (!response.isSuccessful) return@use null + val bytes = try { + remoteCoverHttpClientProvider().newCall(request).awaitResponse { response -> + if (!response.isSuccessful) return@awaitResponse null val contentType = response.header("Content-Type") ?.substringBefore(';') ?.trim() ?.lowercase(Locale.ROOT) if (contentType != null && !contentType.startsWith("image/")) { - return@use null + return@awaitResponse null } val body = response.body val contentLength = body.contentLength() - if (contentLength > MAX_COVER_BYTES) return@use null + if (contentLength > MAX_COVER_BYTES) return@awaitResponse null body.byteStream().use { input -> input.readBytesLimited(MAX_COVER_BYTES) } } - }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + }?.takeIf { it.isNotEmpty() } ?: return null + currentCoroutineContext().ensureActive() if (!remoteCoverImageValidator(bytes)) { return null } + currentCoroutineContext().ensureActive() // 网络封面先保存为应用内输入, 最终本体由音频旁的 Covers 侧载承载 return persistCoverBytes( context = context, diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt index e8fb167f8a..9d06c21ee1 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt @@ -82,6 +82,7 @@ internal val MP4_SUPPORTED_COVER_MIME_TYPES = setOf( ) internal val EDITABLE_COVER_JPEG_QUALITIES = intArrayOf(95, 90, 85, 80, 75, 70, 65, 60) internal const val EDITABLE_METADATA_WRITE_BUDGET_MS = 3_000L +internal const val LOCAL_METADATA_IO_BUFFER_BYTES = 128 * 1024 internal val SAF_WRITE_READBACK_DELAYS_MS = longArrayOf(0L, 8L, 24L) internal const val LOCAL_METADATA_SUFFIX = ".npmeta.json" internal const val LEGACY_DOWNLOAD_ROOT = "/storage/emulated/0/neriplayer-download" diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt index 607504fd6f..835479b89a 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt @@ -469,6 +469,8 @@ internal fun LocalMediaSupport.buildLocalLyricsMetadataJsonImpl( originalValue = song.originalRomanizedLyric, clearMissing = clearMissingLyricFields ) + updateRestorableLocalMetadataOverrides(root, song, writeMetadata = false, + writeLyrics = true, clearMissingLyrics = clearMissingLyricFields) return root.toString() } @@ -519,11 +521,12 @@ internal fun LocalMediaSupport.buildEditableLocalMetadataJsonImpl( } if (writeLyrics) { + // 完整回写使用与音频和歌词侧载相同的当前值,已确认的空基线不能留下旧覆盖 updateLyricMetadataField( root = root, matchedKey = "matchedLyric", originalKey = "originalLyric", - matchedValue = song.matchedLyric, + matchedValue = song.matchedLyric ?: song.originalLyric, originalValue = song.originalLyric, clearMissing = true ) @@ -531,7 +534,7 @@ internal fun LocalMediaSupport.buildEditableLocalMetadataJsonImpl( root = root, matchedKey = "matchedTranslatedLyric", originalKey = "originalTranslatedLyric", - matchedValue = song.matchedTranslatedLyric, + matchedValue = song.matchedTranslatedLyric ?: song.originalTranslatedLyric, originalValue = song.originalTranslatedLyric, clearMissing = true ) @@ -539,7 +542,7 @@ internal fun LocalMediaSupport.buildEditableLocalMetadataJsonImpl( root = root, matchedKey = "matchedRomanizedLyric", originalKey = "originalRomanizedLyric", - matchedValue = song.matchedRomanizedLyric, + matchedValue = song.matchedRomanizedLyric ?: song.originalRomanizedLyric, originalValue = song.originalRomanizedLyric, clearMissing = true ) @@ -547,5 +550,39 @@ internal fun LocalMediaSupport.buildEditableLocalMetadataJsonImpl( putValue("matchedSongId", song.matchedSongId) putValue("userLyricOffsetMs", song.userLyricOffsetMs) } + updateRestorableLocalMetadataOverrides(root, song, writeMetadata = true, + writeLyrics = writeLyrics, clearMissingLyrics = true) return root.toString() } + +private fun updateRestorableLocalMetadataOverrides( + root: JSONObject, + song: SongItem, + writeMetadata: Boolean, + writeLyrics: Boolean, + clearMissingLyrics: Boolean +) { + val restorable = root.optJSONObject("restorableMetadata") ?: return + val overrides = restorable.optJSONObject("overrides") ?: JSONObject().also { + restorable.put("overrides", it) + } + fun update(key: String, value: Any?) { + if (value == null) overrides.remove(key) else overrides.put(key, value) + } + // 下载目录也读取嵌套覆盖值,必须与本次侧载提交一起更新,避免恢复后读回旧值 + if (writeMetadata) { + update("title", song.customName) + update("artist", song.customArtist) + update("coverReference", song.customCoverUrl) + } + if (writeLyrics) { + fun updateLyric(key: String, value: String?) { + if (value != null || clearMissingLyrics) update(key, value) + } + updateLyric("originalLyric", song.matchedLyric) + updateLyric("translatedLyric", song.matchedTranslatedLyric) + updateLyric("romanizedLyric", song.matchedRomanizedLyric) + update("userLyricOffsetMs", song.userLyricOffsetMs) + root.put("userLyricOffsetMs", song.userLyricOffsetMs) + } +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt index 115fdee92f..f290f7d124 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt @@ -624,7 +624,7 @@ internal fun LocalMediaSupport.writeEditableMetadataThroughStagedContentCopy( ?: context.contentResolver.openInputStream(sourceUri) val copied = sourceInput?.use { input -> FileOutputStream(backup).use { output -> - val copiedBytes = input.copyTo(output) + val copiedBytes = input.copyTo(output, LOCAL_METADATA_IO_BUFFER_BYTES) output.fd.sync() if (copiedBytes <= 0L) { throw IOException("staged source copy is empty") @@ -650,7 +650,7 @@ internal fun LocalMediaSupport.writeEditableMetadataThroughStagedContentCopy( } FileInputStream(backup).use { input -> FileOutputStream(updated).use { output -> - input.copyTo(output) + input.copyTo(output, LOCAL_METADATA_IO_BUFFER_BYTES) output.fd.sync() } } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaMetadataRecoveryStore.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaMetadataRecoveryStore.kt index 1b43854a25..874586ae66 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaMetadataRecoveryStore.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaMetadataRecoveryStore.kt @@ -621,7 +621,7 @@ object LocalMediaMetadataRecoveryStore { Os.ftruncate(target.fileDescriptor, 0) Os.lseek(target.fileDescriptor, 0, OsConstants.SEEK_SET) ParcelFileDescriptor.AutoCloseOutputStream(target.dup()).use { output -> - input.copyTo(output) + input.copyTo(output, LOCAL_METADATA_IO_BUFFER_BYTES) output.flush() Os.fsync(target.fileDescriptor) } @@ -657,7 +657,7 @@ object LocalMediaMetadataRecoveryStore { internal fun sha256(input: java.io.InputStream): String { val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + val buffer = ByteArray(LOCAL_METADATA_IO_BUFFER_BYTES) while (true) { val read = input.read(buffer) if (read < 0) break diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorageTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorageTest.kt index 08d90b22d4..706fbaecb2 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorageTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/CustomSongCoverStorageTest.kt @@ -3,6 +3,10 @@ package moe.ouom.neriplayer.data.local.media import android.content.Context import android.content.ContentResolver import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import moe.ouom.neriplayer.data.model.SongItem import okhttp3.OkHttpClient import okhttp3.Protocol @@ -20,12 +24,47 @@ import org.junit.rules.TemporaryFolder import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import okhttp3.Call class CustomSongCoverStorageTest { @get:Rule val tempFolder = TemporaryFolder() + @Test + fun `cancelling remote cover preparation cancels the request before any file is published`() = runBlocking { + val started = CompletableDeferred() + val release = CountDownLatch(1) + val context = mock(Context::class.java) + `when`(context.filesDir).thenReturn(tempFolder.root) + val client = OkHttpClient.Builder().addInterceptor { chain -> + started.complete(chain.call()) + check(release.await(5, TimeUnit.SECONDS)) + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) + .code(200).message("OK").body(byteArrayOf(1, 2, 3).toResponseBody("image/png".toMediaType())) + .build() + }.build() + val previousClient = CustomSongCoverStorage.remoteCoverHttpClientProvider + CustomSongCoverStorage.remoteCoverHttpClientProvider = { client } + val lookup = launch { + CustomSongCoverStorage.persistManuallySelectedRemoteCover(context, "https://example.com/cancel.png") + } + try { + val call = withTimeout(5_000L) { started.await() } + lookup.cancel() + assertTrue("cancellation must reach the active cover request", call.isCanceled()) + assertFalse(File(tempFolder.root, "custom_song_covers").exists()) + } finally { + release.countDown() + lookup.cancelAndJoin() + CustomSongCoverStorage.remoteCoverHttpClientProvider = previousClient + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } + } + @Test fun `prepared covers are found by content and only covers inside the directory are deleted`() = runBlocking { val context = mock(Context::class.java) diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaRestorableSidecarEditTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaRestorableSidecarEditTest.kt new file mode 100644 index 0000000000..49922a0a39 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaRestorableSidecarEditTest.kt @@ -0,0 +1,124 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.Context +import moe.ouom.neriplayer.core.download.storage.metadata.codec.ManagedDownloadedAudioMetadataDecoder +import moe.ouom.neriplayer.data.model.SongItem +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.mock +import java.io.File + +class LocalMediaRestorableSidecarEditTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `restoring metadata removes nested overrides before the download catalog refreshes`() { + val root = write(song(), writeLyrics = true, clearCover = true) + val decoded = ManagedDownloadedAudioMetadataDecoder(root).decode() + + assertNull(decoded.customName) + assertNull(decoded.customArtist) + assertEquals("[00:01.00]original", decoded.matchedLyric) + assertNull(decoded.matchedTranslatedLyric) + assertNull(decoded.matchedRomanizedLyric) + assertEquals(0L, decoded.userLyricOffsetMs) + assertEquals("Original", decoded.restorableMetadata?.baseline?.title) + assertEquals("", root.getJSONObject("restorableMetadata").getJSONObject("baseline").getString("coverReference")) + assertEquals("publication-receipt", root.getString("operationId")) + assertEquals("keep", root.getJSONObject("restorableMetadata").getString("futureField")) + } + + @Test + fun `repeated local edits publish current nested lyrics instead of stale overrides`() { + val edited = song().copy( + customName = "New title", + customArtist = "New artist", + customCoverUrl = "/new-cover.jpg", + matchedLyric = "[00:02.00]new", + matchedTranslatedLyric = "[00:02.00]translation", + matchedRomanizedLyric = "[00:02.00]romanization", + userLyricOffsetMs = 125L + ) + repeat(3) { + val decoded = ManagedDownloadedAudioMetadataDecoder(write(edited, writeLyrics = true)).decode() + assertEquals("New title", decoded.customName) + assertEquals("New artist", decoded.customArtist) + assertEquals("[00:02.00]new", decoded.matchedLyric) + assertEquals("[00:02.00]translation", decoded.matchedTranslatedLyric) + assertEquals("[00:02.00]romanization", decoded.matchedRomanizedLyric) + assertEquals(125L, decoded.userLyricOffsetMs) + assertEquals("Original", decoded.restorableMetadata?.baseline?.title) + } + } + + @Test + fun `metadata only edit leaves nested lyric overrides intact`() { + val decoded = ManagedDownloadedAudioMetadataDecoder(write(song(), writeLyrics = false)).decode() + + assertEquals("[00:09.00]old edit", decoded.matchedLyric) + assertEquals("old translation", decoded.matchedTranslatedLyric) + assertEquals(900L, decoded.userLyricOffsetMs) + assertNull(decoded.customName) + } + + @Test + fun `lyric only save updates nested lyrics without changing title overrides`() { + val root = write(song(), writeLyrics = true, fullMetadata = false) + val decoded = ManagedDownloadedAudioMetadataDecoder(root).decode() + + assertEquals("Edited", decoded.customName) + assertEquals("[00:01.00]original", decoded.matchedLyric) + assertEquals("Original", decoded.restorableMetadata?.baseline?.title) + } + + @Test + fun `restoring a confirmed empty translation removes the previous displayed edit`() { + val restored = song().copy(originalTranslatedLyric = "", originalRomanizedLyric = "") + val decoded = ManagedDownloadedAudioMetadataDecoder(write(restored, writeLyrics = true)).decode() + + assertEquals("", decoded.matchedTranslatedLyric) + assertEquals("", decoded.matchedRomanizedLyric) + } + + private fun write( + song: SongItem, + writeLyrics: Boolean, + clearCover: Boolean = false, + fullMetadata: Boolean = true + ): JSONObject { + val audio = temporaryFolder.newFile("song-${temporaryFolder.root.listFiles().orEmpty().size}.flac") + val sidecar = File(audio.parentFile, audio.name + ".npmeta.json").apply { writeText(existing) } + assertTrue(LocalMediaSupport.writeLocalLyricsMetadataReference( + context = mock(Context::class.java), + reference = sidecar.absolutePath, + file = audio, + song = song, + writeFullMetadata = fullMetadata, + writeLyricFields = writeLyrics, + clearCoverReference = clearCover + )) + return JSONObject(sidecar.readText()) + } + + private fun song() = SongItem( + id = 51L, name = "Original", artist = "Original artist", album = "Album", + albumId = 0L, durationMs = 180_000L, coverUrl = null, + originalName = "Original", originalArtist = "Original artist", originalCoverUrl = "", + originalLyric = "[00:01.00]original", matchedLyric = "[00:01.00]original" + ) + + private val existing = """ + {"operationId":"publication-receipt","customName":"Edited","customArtist":"Edited artist", + "restorableMetadata":{"futureField":"keep","baseline":{"title":"Original", + "artist":"Original artist","coverReference":"","originalLyric":"[00:01.00]original"}, + "overrides":{"title":"Edited","artist":"Edited artist","coverReference":"/old-cover.jpg", + "originalLyric":"[00:09.00]old edit","translatedLyric":"old translation", + "romanizedLyric":"old romanization","userLyricOffsetMs":900}}} + """.trimIndent() +} diff --git a/modules/platform/src/main/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepository.kt b/modules/platform/src/main/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepository.kt index ab894023a3..26dc486cdb 100644 --- a/modules/platform/src/main/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepository.kt +++ b/modules/platform/src/main/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepository.kt @@ -38,6 +38,8 @@ class QQMusicLyricsRepository( override suspend fun getNativeSongInfo(id: String): SongDetails = api.getNativeSongInfo(id) + suspend fun getSongMetadataOnly(id: String): SongDetails = api.getSongMetadata(id).details + private suspend fun fetchAmllWordLyricIfEnabled(metadata: QQMusicSongMetadata): String? { return try { if (amllLyricsEnabledProvider()) { diff --git a/modules/platform/src/main/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApi.kt b/modules/platform/src/main/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApi.kt index 14722b3270..f90e5d7185 100644 --- a/modules/platform/src/main/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApi.kt +++ b/modules/platform/src/main/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApi.kt @@ -84,11 +84,7 @@ class CloudMusicSearchApi( override suspend fun getSongInfo(id: String): SongDetails { return withContext(Dispatchers.IO) { - val songDetailUrl = "https://music.163.com/api/song/detail/?id=${id}&ids=[${id}]" - val songInfoJson = executeRequest(songDetailUrl) as String - val songData = json.decodeFromString(songInfoJson).songs.firstOrNull() - ?: throw IOException("找不到ID为 $id 的歌曲") - + val metadata = getSongMetadataOnly(id) val (lyric, translatedLyric) = try { fetchLyrics(id) } catch (error: CancellationException) { @@ -97,18 +93,28 @@ class CloudMusicSearchApi( NPLogger.w(TAG, "网易云歌词获取失败,继续使用歌曲详情: ${error.message.orEmpty()}") null to null } - SongDetails( - id = id, - songName = songData.name, - singer = songData.artists.joinToString("/") { it.name }, - album = songData.album.name, - coverUrl = songData.album.picUrl, + metadata.copy( lyric = lyric, translatedLyric = translatedLyric ) } } + suspend fun getSongMetadataOnly(id: String): SongDetails = withContext(Dispatchers.IO) { + val songDetailUrl = "https://music.163.com/api/song/detail/?id=${id}&ids=[${id}]" + val songInfoJson = executeRequest(songDetailUrl) as String + val songData = json.decodeFromString(songInfoJson).songs.firstOrNull() + ?: throw IOException("找不到ID为 $id 的歌曲") + SongDetails( + id = id, + songName = songData.name, + singer = songData.artists.joinToString("/") { it.name }, + album = songData.album.name, + coverUrl = songData.album.picUrl, + lyric = null + ) + } + private suspend fun fetchLyrics(id: String): Pair { val lyricUrl = "https://music.163.com/api/song/lyric?id=${id}&lv=-1&tv=-1&rv=-1&yv=-1&ytv=-1&yrv=-1" diff --git a/modules/platform/src/test/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepositoryTest.kt b/modules/platform/src/test/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepositoryTest.kt index 5075d77d30..5fdafddec5 100644 --- a/modules/platform/src/test/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepositoryTest.kt +++ b/modules/platform/src/test/java/moe/ouom/neriplayer/platform/lyrics/repository/QQMusicLyricsRepositoryTest.kt @@ -14,6 +14,7 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test @@ -77,6 +78,18 @@ class QQMusicLyricsRepositoryTest { assertEquals(0, requests.count { it.url.host == "amll.test" }) } + @Test + fun `metadata only lookup sends one detail request without native lyrics or AMLL`() = runTest { + val result = api { throw AssertionError("metadata lookup must not read AMLL preference") } + .getSongMetadataOnly("id") + + assertEquals("Signal", result.songName) + assertNull(result.lyric) + assertNull(result.translatedLyric) + assertEquals(1, requests.size) + assertEquals(true, requests.single().url.queryParameter("data").orEmpty().contains("get_song_detail_yqq")) + } + @Test fun `AMLL preference cancellation propagates`() { val cancellation = CancellationException("cancelled") diff --git a/modules/platform/src/test/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApiTest.kt b/modules/platform/src/test/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApiTest.kt index e185ae36e1..e52437ea39 100644 --- a/modules/platform/src/test/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApiTest.kt +++ b/modules/platform/src/test/java/moe/ouom/neriplayer/platform/search/api/client/CloudMusicSearchApiTest.kt @@ -52,6 +52,19 @@ class CloudMusicSearchApiTest { assertEquals(listOf("fixture=one", "fixture=two"), requests.map { it.header("Cookie") }) } + @Test + fun `metadata only lookup sends one detail request without fetching lyrics`() = runTest { + `when`(netease.getCookies()).thenReturn(emptyMap()) + + val result = CloudMusicSearchApi(netease, client).getSongMetadataOnly("12") + + assertEquals("Signal", result.songName) + assertNull(result.lyric) + assertNull(result.translatedLyric) + assertEquals(1, requests.size) + assertEquals("/api/song/detail/", requests.single().url.encodedPath) + } + @Test fun `lyric failure preserves song details`() = runTest { `when`(netease.getCookies()).thenReturn(emptyMap()) diff --git a/modules/playback/runtime/README.md b/modules/playback/runtime/README.md index 57b4ab9122..7a6561a358 100644 --- a/modules/playback/runtime/README.md +++ b/modules/playback/runtime/README.md @@ -46,6 +46,20 @@ The app installs `PlayerDependencies` in `core/di/player`; `isInitialized()` che | Recovery Timing | Resuming retains the current playback cycle's counted state and restarts timing from the recovery point without fabricating data for the paused interval | | Process Exit & Frame Replay | Unpersisted handoff events may be lost on process exit; published durable frames retry with their original IDs, and leftover temporary frames never block the confirmed prefix | +## 歌曲信息获取 / Song information acquisition + +手动获取歌曲信息时可选择歌词来源、原文、翻译、音译及逐词偏好;未选择或未获取到的歌词内容会保留原值。本地歌曲可选择回写音频或仅应用内保存。界面显示歌曲信息、歌词、封面和保存进度,准备期间可取消;开始保存校验后等待本轮提交完成,进度走完并显示完成状态后才关闭。选项与保存位置分组排列,小屏和大字体下可滚动选择,底部操作保持可见。 + +Manual song information acquisition supports lyric sources, original, translated and romanized lyrics, and word-timing preference. Unselected or unavailable lyric content retains its current value. Local songs offer audio write-back or app-only persistence. The UI shows song information, lyric, artwork and save progress. Preparation can be cancelled; once saving and verification start, the current commit finishes before the progress reaches the end and the completed state is shown, then the sheet closes. Options and save destinations are grouped, with scrollable choices and visible actions on small screens or with larger text. + +播放中的本地信息补全在提交时校验播放请求及歌曲快照,并与手动保存共用元数据锁,防止旧读取覆盖刚保存或恢复的信息。明确恢复为无封面时,媒体会话和通知清除旧图及未完成的图片请求;暂时未解析到封面时仍保留当前画面。 + +Local playback hydration validates the playback request and song snapshots when publishing, using the same metadata lock as manual saves so an older read cannot overwrite saved or restored values. A confirmed restoration to no artwork clears media-session and notification images and pending image requests; temporarily unresolved artwork retains the current frame. + +媒体通知合并密集更新并确认实际发布的最后一条内容,系统静默丢弃时有限重试。退出前台或销毁服务时取消待发通知与确认任务,初次前台提升仍立即执行。 + +Media notifications coalesce rapid updates and confirm the latest publication, retrying a silent system drop within a fixed limit. Leaving foreground or destroying the service cancels pending publications and confirmations; initial foreground promotion remains immediate. + ## 兼容 / Compatibility [UsbExclusiveNativeBridge](src/main/java/moe/ouom/neriplayer/core/player/usb/transport/UsbExclusiveNativeBridge.kt) 的完整类名、native 方法名及 FFmpeg 回调须与 JNI 和 [consumer R8 规则](consumer-rules.pro) 一致;[Manifest](src/main/AndroidManifest.xml) 中的服务类名也须保持兼容。FFmpeg AAR 仅用作编译和测试接口,由 app 打包;[`:native`](../../native/README.md) 维护 C/C++ 实现并通过 AAR 向 app 提供 `lib_neri.so`,Kotlin 桥仍在本模块。 diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt index 70a89b3a29..2ad195c311 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt @@ -184,6 +184,8 @@ import moe.ouom.neriplayer.core.player.persistence.removeQueueItemImpl import moe.ouom.neriplayer.core.player.persistence.removeQueueItemsImpl import moe.ouom.neriplayer.core.player.persistence.replaceCurrentInQueueAndPlayImpl import moe.ouom.neriplayer.core.player.persistence.replaceMetadataFromSearchImpl +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage import moe.ouom.neriplayer.core.player.persistence.resumeRestoredPlaybackIfNeededImpl import moe.ouom.neriplayer.core.player.persistence.moveQueueItemImpl import moe.ouom.neriplayer.core.player.persistence.reorderQueueImpl @@ -1901,8 +1903,10 @@ object PlayerManager { originalSong: SongItem, selectedSong: SongSearchInfo, isAuto: Boolean = false, - onComplete: ((Boolean) -> Unit)? = null - ) = replaceMetadataFromSearchImpl(originalSong, selectedSong, isAuto, onComplete) + onComplete: ((Boolean) -> Unit)? = null, + options: SongMetadataApplyOptions? = null, + onProgress: ((SongMetadataApplyStage) -> Unit)? = null + ): Job? = replaceMetadataFromSearchImpl(originalSong, selectedSong, isAuto, onComplete, options, onProgress) suspend fun updateSongCustomInfo( originalSong: SongItem, diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt index 35c3683ec9..15818537c9 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt @@ -10,11 +10,20 @@ import moe.ouom.neriplayer.data.model.sync.nextLyricSyncRevision internal fun shouldSkipSongMetadataMutation( currentSong: SongItem, updatedSong: SongItem, - writeLyrics: Boolean + writeLyrics: Boolean, + writeLocalMetadata: Boolean = false ): Boolean { - return !writeLyrics && currentSong == updatedSong + return !writeLyrics && !writeLocalMetadata && currentSong == updatedSong } +internal fun firstUsableLocalCoverReference(vararg candidates: () -> String?): String? = + candidates.asSequence().map { it() }.firstOrNull { reference -> + !reference.isNullOrBlank() && + !reference.startsWith("http://", ignoreCase = true) && + !reference.startsWith("https://", ignoreCase = true) && + !isMediaStoreCoverReference(reference) + } + internal fun SongItem.withUpdatedLyricsPreservingOriginal( newLyrics: String? = matchedLyric, newTranslatedLyric: String? = matchedTranslatedLyric, diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyrics.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyrics.kt new file mode 100644 index 0000000000..6fda702ffd --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyrics.kt @@ -0,0 +1,140 @@ +package moe.ouom.neriplayer.core.player.metadata + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.lyrics.LyricEntry +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchRequest +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.lyrics.matching.RankedEditableLyricMatch +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongDetails +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import moe.ouom.neriplayer.lyrics.parser.toEditableLyricsText + +internal data class SelectedSongMetadataLyrics( + val original: String? = null, + val translated: String? = null, + val romanized: String? = null, + val source: MusicPlatform? = null, + val songId: String? = null +) { + val hasUpdates: Boolean get() = original != null || translated != null || romanized != null +} + +internal suspend fun fetchSelectedSongMetadataLyrics( + originalSong: SongItem, + selectedSong: SongSearchInfo, + details: SongDetails, + options: SongMetadataApplyOptions, + matchLyrics: suspend (EditableLyricMatchRequest) -> List, + loadRomanized: suspend (Long) -> List +): SelectedSongMetadataLyrics { + if (!options.fetchesLyrics) return SelectedSongMetadataLyrics() + val duration = originalSong.durationMs.takeIf { it > 0L } ?: selectedSong.duration.toDurationMs() + val request = EditableLyricMatchRequest( + keyword = "${details.songName} ${details.singer}".trim(), + trackName = details.songName, + artistName = details.singer, + albumName = details.album, + durationMs = duration, + preferWordTimed = options.preferWordTimed, + sources = options.lyricSources.toSet() + ) + val matches = try { + matchLyrics(request) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + emptyList() + } + currentCoroutineContext().ensureActive() + var result = SelectedSongMetadataLyrics() + val romanizedLookups = mutableSetOf() + for (match in matches) { + currentCoroutineContext().ensureActive() + if (match.candidate.source !in options.lyricSources) continue + val resolved = PlayerLyricsProvider.selectFirstUsableAutomaticExternalLyrics( + expectedDurationMs = duration, + expectedTitle = details.songName, + expectedArtist = details.singer, + matches = listOf(match) + ) ?: continue + val original = resolved.lyrics.toEditableLyricsText().takeIf(String::isNotBlank) + val translated = resolved.translatedLyrics.ifEmpty { + resolved.lyrics.mapNotNull { entry -> + entry.translation?.takeIf(String::isNotBlank)?.let { translation -> + entry.copy(text = translation, words = null, translation = null) + } + } + }.toEditableLyricsText().takeIf(String::isNotBlank) + val romanized = if (options.romanizedLyrics && result.romanized == null) { + resolved.romanizedLyrics.ifEmpty { + resolved.neteaseSongId?.takeIf(romanizedLookups::add)?.let { loadRomanized(it) }.orEmpty() + }.toEditableLyricsText().takeIf(String::isNotBlank) + } else null + val next = result.copy( + original = result.original ?: original.takeIf { options.originalLyrics }, + translated = result.translated ?: translated.takeIf { options.translatedLyrics }, + romanized = result.romanized ?: romanized + ) + if (!result.hasUpdates && next.hasUpdates) { + val platform = when (match.candidate.source) { + EditableLyricMatchSource.CLOUD_MUSIC -> MusicPlatform.CLOUD_MUSIC + EditableLyricMatchSource.QQ_MUSIC -> MusicPlatform.QQ_MUSIC + else -> null + } + result = next.copy(source = platform, songId = match.candidate.id.takeIf { platform != null }) + } else { + result = next + } + if ((!options.originalLyrics || result.original != null) && + (!options.translatedLyrics || result.translated != null) && + (!options.romanizedLyrics || result.romanized != null) + ) break + } + currentCoroutineContext().ensureActive() + return result +} + +private fun String.toDurationMs(): Long { + val parts = trim().split(':').map { it.toLongOrNull() ?: return 0L } + if (parts.size !in 2..3 || parts.any { it < 0L }) return 0L + return parts.fold(0L) { seconds, part -> seconds * 60L + part } * 1_000L +} + +internal fun applySelectedSongMetadata( + originalSong: SongItem, + selectedSong: SongSearchInfo, + details: SongDetails, + coverReference: String?, + lyrics: SelectedSongMetadataLyrics, + useCustomOverride: Boolean, + lyricRevision: Long, + captureMatchedOriginals: Boolean +): SongItem { + val updated = applyManualSearchMetadata( + originalSong = originalSong, + songName = details.songName, + singer = details.singer, + coverUrl = coverReference, + lyric = null, + translatedLyric = null, + matchedSource = selectedSong.source, + matchedSongId = selectedSong.id, + useCustomOverride = useCustomOverride, + preserveExistingMatchedLyrics = true, + lyricRevision = lyricRevision, + captureMatchedOriginals = captureMatchedOriginals + ) + return updated.copy( + matchedLyric = lyrics.original ?: originalSong.matchedLyric, + matchedTranslatedLyric = lyrics.translated ?: originalSong.matchedTranslatedLyric, + matchedRomanizedLyric = lyrics.romanized ?: originalSong.matchedRomanizedLyric, + lyricSyncEdited = if (lyrics.hasUpdates) true else originalSong.lyricSyncEdited, + lyricSyncRevision = if (lyrics.hasUpdates) lyricRevision else originalSong.lyricSyncRevision, + matchedLyricSource = if (lyrics.hasUpdates) lyrics.source else originalSong.matchedLyricSource, + matchedSongId = if (lyrics.hasUpdates) lyrics.songId else originalSong.matchedSongId + ) +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SongMetadataApplyOptions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SongMetadataApplyOptions.kt new file mode 100644 index 0000000000..2825545929 --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/SongMetadataApplyOptions.kt @@ -0,0 +1,24 @@ +package moe.ouom.neriplayer.core.player.metadata + +import moe.ouom.neriplayer.data.model.lyrics.matching.DEFAULT_EDITABLE_LYRIC_MATCH_SOURCES +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource + +data class SongMetadataApplyOptions( + val lyricSources: Set = DEFAULT_EDITABLE_LYRIC_MATCH_SOURCES.toSet(), + val originalLyrics: Boolean = true, + val translatedLyrics: Boolean = true, + val romanizedLyrics: Boolean = true, + val preferWordTimed: Boolean = true, + val writeLocalMetadata: Boolean = false +) { + internal val fetchesLyrics: Boolean + get() = lyricSources.isNotEmpty() && (originalLyrics || translatedLyrics || romanizedLyrics) +} + +enum class SongMetadataApplyStage { + FETCHING_DETAILS, + FETCHING_LYRICS, + PREPARING_COVER, + PREPARING_METADATA, + APPLYING +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerManualMetadataApply.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerManualMetadataApply.kt new file mode 100644 index 0000000000..013897f0c6 --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerManualMetadataApply.kt @@ -0,0 +1,151 @@ +package moe.ouom.neriplayer.core.player.persistence + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.metadata.PlayerLyricsProvider +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import moe.ouom.neriplayer.core.player.metadata.applySelectedSongMetadata +import moe.ouom.neriplayer.core.player.metadata.fetchSelectedSongMetadataLyrics +import moe.ouom.neriplayer.core.player.metadata.toBasicSongDetails +import moe.ouom.neriplayer.data.identity.sameIdentityAs +import moe.ouom.neriplayer.data.local.media.CustomSongCoverStorage +import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import moe.ouom.neriplayer.data.model.playback.storage.PlayerDownloadedMetadataSyncOutcome + +internal suspend fun PlayerManager.applyManualMetadataSelection( + originalSong: SongItem, + selectedSong: SongSearchInfo, + options: SongMetadataApplyOptions, + isLatestRequest: () -> Boolean, + onProgress: suspend (SongMetadataApplyStage) -> Unit, + onApplied: () -> Unit = {} +): Boolean { + suspend fun report(stage: SongMetadataApplyStage) { + currentCoroutineContext().ensureActive() + if (!isLatestRequest()) throw CancellationException("metadata request superseded") + onProgress(stage) + } + report(SongMetadataApplyStage.FETCHING_DETAILS) + val details = try { + when (selectedSong.source) { + MusicPlatform.CLOUD_MUSIC -> PlayerDependencies.repositories.cloudMusicSearchApi.getSongMetadataOnly(selectedSong.id) + MusicPlatform.QQ_MUSIC -> PlayerDependencies.repositories.qqMusicSearchApi.getSongMetadataOnly(selectedSong.id) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + NPLogger.w("NERI-PlayerManager", "Song detail lookup failed, using search summary: ${error.message.orEmpty()}") + selectedSong.toBasicSongDetails() + } + if (options.fetchesLyrics) report(SongMetadataApplyStage.FETCHING_LYRICS) + val lyrics = fetchSelectedSongMetadataLyrics( + originalSong = originalSong, + selectedSong = selectedSong, + details = details, + options = options, + matchLyrics = { PlayerDependencies.repositories.editableLyricsMatcher.matchLyrics(it) }, + loadRomanized = { id -> + PlayerLyricsProvider.getNeteaseRomanizedLyrics(id, neteaseClient, neteaseLyricsCache) + } + ) + report(SongMetadataApplyStage.PREPARING_COVER) + val base = latestManualMetadataSong(originalSong) + val isLocal = LocalSongSupport.isLocalSong(base, application) + val cover = details.coverUrl?.takeIf(String::isNotBlank)?.let { reference -> + if (isLocal && CustomSongCoverStorage.isRemoteReference(reference)) { + CustomSongCoverStorage.persistManuallySelectedRemoteCover(application, reference) + ?: return false + } else reference + } + report(SongMetadataApplyStage.PREPARING_METADATA) + val prepared = preserveLocalSongMetadataOriginals( + song = base, + preserveCover = cover != null, + preserveLyrics = lyrics.hasUpdates + ) + return runSongMetadataMutation { + currentCoroutineContext().ensureActive() + if (!isLatestRequest()) return@runSongMetadataMutation false + // 取消只在准备阶段生效,文件提交和内存发布必须完成同一轮更新 + withContext(NonCancellable) { + onProgress(SongMetadataApplyStage.APPLYING) + if (!isLatestRequest()) return@withContext false + val latest = latestManualMetadataSong(originalSong).withPreparedOriginals(prepared) + val local = LocalSongSupport.isLocalSong(latest, application) + val updated = applySelectedSongMetadata( + originalSong = latest, + selectedSong = selectedSong, + details = details, + coverReference = cover ?: latest.customCoverUrl ?: latest.coverUrl, + lyrics = lyrics, + useCustomOverride = local || PlayerDependencies.downloads.getLocalPlaybackUri(application, latest) != null, + lyricRevision = if (lyrics.hasUpdates) localRepo.nextUserLyricSyncRevision(latest) else latest.lyricSyncRevision, + captureMatchedOriginals = !local && lyrics.hasUpdates + ) + if (local) { + val coverReference = updated.customCoverUrl ?: cover + val outcome = if (options.writeLocalMetadata) { + writeLocalEditableMetadata( + song = updated, + coverReference = coverReference, + writeCover = coverReference != null, + writeLyrics = lyrics.hasUpdates + ) + } else { + val writable = resolveLocalSidecarWriteSong(updated) + if (writable != null && LocalMediaSupport.writeLocalMetadataSidecar( + context = application, + song = writable, + writeLyrics = lyrics.hasUpdates, + coverReference = coverReference + )) LocalMediaMetadataWriteOutcome.SUCCESS else LocalMediaMetadataWriteOutcome.FAILED + } + if (outcome != LocalMediaMetadataWriteOutcome.SUCCESS) { + showLocalEditableMetadataWriteFeedback(outcome, PlayerDownloadedMetadataSyncOutcome.NOT_DOWNLOADED) + return@withContext false + } + } + updateSongInAllPlaces( + originalSong = latest, + updatedSong = updated, + triggerSync = true, + syncDownloadedMetadata = shouldSyncDownloadedMetadataAfterMetadataUpdate( + writeLocalMetadata = options.writeLocalMetadata, + isLocalSong = local + ), + fastLocalUsageSync = true + ) + onApplied() + true + } + } +} + +private fun PlayerManager.latestManualMetadataSong(song: SongItem): SongItem = + currentQueueSnapshot().playlist.firstOrNull { it.sameIdentityAs(song) } + ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(song) } + ?: localRepo.playlists.value.firstNotNullOfOrNull { playlist -> + playlist.songs.firstOrNull { it.sameIdentityAs(song) } + } + ?: song + +private fun SongItem.withPreparedOriginals(prepared: SongItem): SongItem = copy( + originalName = originalName ?: prepared.originalName, + originalArtist = originalArtist ?: prepared.originalArtist, + originalCoverUrl = originalCoverUrl ?: prepared.originalCoverUrl, + originalLyric = originalLyric ?: prepared.originalLyric, + originalTranslatedLyric = originalTranslatedLyric ?: prepared.originalTranslatedLyric, + originalRomanizedLyric = originalRomanizedLyric ?: prepared.originalRomanizedLyric +) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt index b26ae53925..345ff32b45 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt @@ -48,12 +48,14 @@ internal suspend fun PlayerManager.confirmLocalCoverBaseline(song: SongItem, ref internal suspend fun PlayerManager.preserveLocalSongMetadataOriginals( song: SongItem, - preserveCover: Boolean = false + preserveCover: Boolean = false, + preserveLyrics: Boolean = true ): SongItem = withContext(Dispatchers.IO) { if (!LocalSongSupport.isLocalSong(song, application)) return@withContext song val localSong = resolveLocalSidecarWriteSong(song) ?: song val metadata = LocalMediaSupport.readLocalMetadataSidecarFast(application, localSong) val lyrics = if ( + preserveLyrics && song.lyricSyncEdited != true && (song.originalLyric == null || song.originalTranslatedLyric == null || song.originalRomanizedLyric == null) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt index 4f400a6ff5..c568c09930 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt @@ -12,6 +12,7 @@ import android.os.SystemClock import androidx.media3.common.Player import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -28,9 +29,12 @@ import moe.ouom.neriplayer.core.player.host.PlayerDependencies import moe.ouom.neriplayer.data.model.playback.storage.PlayerMetadataClearRequest as RestorableMetadataClearPolicy import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.core.player.metadata.applyManualSearchMetadata +import moe.ouom.neriplayer.core.player.metadata.firstUsableLocalCoverReference import moe.ouom.neriplayer.core.player.metadata.normalizeCustomMetadataValue import moe.ouom.neriplayer.core.player.metadata.BiliSubtitleLyricsOptions import moe.ouom.neriplayer.core.player.metadata.PlayerLyricsProvider +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage import moe.ouom.neriplayer.core.player.metadata.PreferredLyricSourceResult import moe.ouom.neriplayer.data.model.settings.lyrics.LyricSourcePreference import moe.ouom.neriplayer.core.player.runtime.metadata.SongMetadataRequestCoordinator @@ -807,15 +811,17 @@ internal fun PlayerManager.replaceMetadataFromSearchImpl( originalSong: SongItem, selectedSong: SongSearchInfo, isAuto: Boolean = false, - onComplete: ((Boolean) -> Unit)? = null -) { + onComplete: ((Boolean) -> Unit)? = null, + options: SongMetadataApplyOptions? = null, + onProgress: ((SongMetadataApplyStage) -> Unit)? = null +): Job? { val requestToken = songMetadataRequestCoordinator.begin( songKey = originalSong.stableKey(), isAuto = isAuto ) if (requestToken == null) { dispatchMetadataReplacementCompletion(onComplete, applied = false) - return + return null } val applied = AtomicBoolean(false) @@ -839,6 +845,28 @@ internal fun PlayerManager.replaceMetadataFromSearchImpl( return@launch } + if (options != null && !isAuto) { + val requestContext = coroutineContext + applied.set(applyManualMetadataSelection( + originalSong = originalSong, + selectedSong = selectedSong, + options = options, + isLatestRequest = { songMetadataRequestCoordinator.isLatest(requestToken) }, + onProgress = { stage -> + val callback = onProgress + if (callback != null) withContext(Dispatchers.Main.immediate) { + requestContext.ensureActive() + if (!songMetadataRequestCoordinator.isLatest(requestToken)) { + throw CancellationException("metadata request superseded") + } + callback(stage) + } + }, + onApplied = { applied.set(true) } + )) + return@launch + } + val api = when (platform) { MusicPlatform.CLOUD_MUSIC -> cloudMusicSearchApi MusicPlatform.QQ_MUSIC -> qqMusicSearchApi @@ -965,6 +993,7 @@ internal fun PlayerManager.replaceMetadataFromSearchImpl( songMetadataRequestCoordinator.complete(requestToken) dispatchMetadataReplacementCompletion(onComplete, applied.get()) } + return replacementJob } private fun PlayerManager.shouldApplySearchMetadataAsCustomOverride(song: SongItem): Boolean { @@ -1033,11 +1062,17 @@ internal suspend fun PlayerManager.writeLocalEditableMetadata( val writableSong = resolveLocalSidecarWriteSong(song) ?.takeIf { LocalSongSupport.isLocalSong(it, application) } ?: return LocalMediaMetadataWriteOutcome.NOT_WRITABLE + val writeSong = if (writeLyrics) { + completeUnknownLocalWriteLyrics(writableSong) + ?: return LocalMediaMetadataWriteOutcome.FAILED + } else { + writableSong + } val playbackSnapshot = parkCurrentPlaybackForLocalMetadataWrite(song) return try { LocalMediaSupport.writeEditableMetadata( context = application, - song = writableSong, + song = writeSong, coverReference = coverReference, writeCover = writeCover, writeLyrics = writeLyrics @@ -1047,6 +1082,30 @@ internal suspend fun PlayerManager.writeLocalEditableMetadata( } } +private suspend fun PlayerManager.completeUnknownLocalWriteLyrics(song: SongItem): SongItem? { + val missingOriginal = song.matchedLyric == null && song.originalLyric == null + val missingTranslated = song.matchedTranslatedLyric == null && song.originalTranslatedLyric == null + val missingRomanized = song.matchedRomanizedLyric == null && song.originalRomanizedLyric == null + if (!missingOriginal && !missingTranslated && !missingRomanized) return song + val embedded = try { + withContext(Dispatchers.IO) { + LocalMediaSupport.inspectEmbeddedLyrics(application, song) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + NPLogger.w("PlayerManager", "本地回写前未能确认现有歌词: ${error.message}") + null + } + if (embedded?.embeddedSourceResolved != true) return null + // 当前文件里的未知轨只用于本次回写,不能冒充已经丢失的原始恢复数据 + return song.copy( + matchedLyric = if (missingOriginal) embedded.lyric.orEmpty() else song.matchedLyric, + matchedTranslatedLyric = if (missingTranslated) embedded.translatedLyric.orEmpty() else song.matchedTranslatedLyric, + matchedRomanizedLyric = if (missingRomanized) embedded.romanizedLyric.orEmpty() else song.matchedRomanizedLyric + ) +} + private suspend fun PlayerManager.resolveLocalMetadataWriteReference( song: SongItem ): String? { @@ -1073,7 +1132,7 @@ internal suspend fun PlayerManager.resolveLocalSidecarWriteSong(song: SongItem): ) } -private fun PlayerManager.showLocalEditableMetadataWriteFeedback( +internal fun PlayerManager.showLocalEditableMetadataWriteFeedback( outcome: LocalMediaMetadataWriteOutcome, downloadSyncOutcome: PlayerDownloadedMetadataSyncOutcome ) { @@ -1124,7 +1183,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( val latestSong = currentPlaylist.firstOrNull { it.sameIdentityAs(originalSong) } ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(originalSong) } ?: originalSong - val currentSong = preserveLocalSongMetadataOriginals(latestSong) + val currentSong = preserveLocalSongMetadataOriginals(latestSong, preserveLyrics = writeLyrics) val isLocalSong = LocalSongSupport.isLocalSong(currentSong, application) val baseName = currentSong.name @@ -1168,17 +1227,13 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( val shouldResolveLocalCover = isLocalSong && (coverChanged || restoreBaseCover) val discoveredLocalCoverUrl = if (shouldResolveLocalCover) { - sequenceOf( + firstUsableLocalCoverReference( // 下载歌曲的 Covers 侧载是本地恢复的第一来源, 不要让远程标签覆盖它 - PlayerDependencies.downloads.peekLocalCoverUri(currentSong), - LocalMediaSupport.resolveNearbyCoverUri(application, currentSong), - LocalMediaSupport.peekCachedEmbeddedCoverUri(application, currentSong), - LocalMediaSupport.resolveCoverUri(application, currentSong) - ).firstOrNull { reference -> - !reference.isNullOrBlank() && - !CustomSongCoverStorage.isRemoteReference(reference) && - !isMediaStoreCoverReference(reference) - } + { PlayerDependencies.downloads.peekLocalCoverUri(currentSong) }, + { LocalMediaSupport.resolveNearbyCoverUri(application, currentSong) }, + { LocalMediaSupport.peekCachedEmbeddedCoverUri(application, currentSong) }, + { LocalMediaSupport.resolveCoverUri(application, currentSong) } + ) } else { null } @@ -1257,7 +1312,11 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( restoredBaseCoverReference = restoredBaseCoverUrl ) // null is a deliberate clear when the user restores or removes a cover - val shouldWriteCoverToAudio = coverChanged + val shouldWriteCoverToAudio = coverChanged || + (writeLocalMetadata && requestedCoverReference != null) || + (writeLocalMetadata && isLocalSong && currentSong.originalCoverUrl == "" && + requestedCoverReference == null && currentSong.customCoverUrl.isNullOrBlank() && + baseCoverUrl.isNullOrBlank()) val nextBaseName = if (restoreBaseName) restoredBaseName else baseName val nextBaseArtist = if (restoreBaseArtist) restoredBaseArtist else baseArtist @@ -1314,7 +1373,10 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( matchedSongId = if (clearMatchedMetadata) null else currentSong.matchedSongId ) - if (shouldSkipSongMetadataMutation(currentSong, updatedSong, writeLyrics)) { + if (shouldSkipSongMetadataMutation( + currentSong, updatedSong, writeLyrics, + writeLocalMetadata = writeLocalMetadata && isLocalSong + )) { NPLogger.d("PlayerManager", "skip unchanged song metadata mutation") return@runSongMetadataMutation true } @@ -1390,8 +1452,17 @@ private fun String?.normalizedManualMetadataValue(): String? { } internal fun PlayerManager.hydrateSongMetadataImpl(originalSong: SongItem, updatedSong: SongItem) { + val queuedSongAtRequest = currentPlaylist.firstOrNull { it.sameIdentityAs(originalSong) } + val currentSongAtRequest = _currentSongFlow.value?.takeIf { it.sameIdentityAs(originalSong) } + if (queuedSongAtRequest?.let { it != originalSong } == true || + currentSongAtRequest?.let { it != originalSong } == true + ) return ioScope.launch { runSongMetadataMutation { + // 输入可以是相等的副本,提交时仍校验实际快照是否已被后续保存替换 + if (currentPlaylist.firstOrNull { it.sameIdentityAs(originalSong) } !== queuedSongAtRequest || + _currentSongFlow.value?.takeIf { it.sameIdentityAs(originalSong) } !== currentSongAtRequest + ) return@runSongMetadataMutation NPLogger.d( "NERI-PlayerManager", "hydrateSongMetadata: original=${originalSong.name}/${originalSong.id}, updated=${updatedSong.name}/${updatedSong.id}, stack=[${debugStackHint()}]" diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt index 2dec139e6d..1389821789 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt @@ -39,6 +39,7 @@ import moe.ouom.neriplayer.data.model.playback.PlayerEvent import moe.ouom.neriplayer.data.model.playback.SongUrlResult import moe.ouom.neriplayer.core.player.persistence.persistStateNow import moe.ouom.neriplayer.core.player.persistence.prepareStatePersist +import moe.ouom.neriplayer.core.player.persistence.runSongMetadataMutation import moe.ouom.neriplayer.core.player.persistence.scheduleStatePersist import moe.ouom.neriplayer.data.model.playback.PlaybackCommandSource import moe.ouom.neriplayer.core.player.policy.command.PlaybackStartPlan @@ -937,6 +938,7 @@ private fun PlayerManager.maybeHydrateSongForPlayback( return } + val currentSongAtRequest = _currentSongFlow.value?.takeIf { it.sameIdentityAs(song) } ioScope.launch { val isPublishedManagedDownload = PlayerDependencies.downloads.hasDownloadedSongCached(song) // 目录和 URI 线索只决定是否做轻量文本补全,不代表下载已经完成 @@ -958,34 +960,41 @@ private fun PlayerManager.maybeHydrateSongForPlayback( return@launch } - var applied = false withContext(Dispatchers.Main) { - if (requestToken != playbackRequestToken) { - return@withContext - } - applied = updateCurrentQueueSongs { playlist -> - if (index !in playlist.indices || !playlist[index].sameIdentityAs(song)) { - return@updateCurrentQueueSongs null - } - playlist.toMutableList().also { it[index] = hydratedSong } - } != null - if (!applied) return@withContext - if (_currentSongFlow.value?.sameIdentityAs(song) == true) { - setCurrentSongForPlayback(hydratedSong, syncLyricon = false) - } + if (requestToken != playbackRequestToken) return@withContext + publishHydratedLocalPlaybackSong(index, song, hydratedSong, currentSongAtRequest, requestToken) } + } +} - if (!applied) { - return@launch +private suspend fun PlayerManager.publishHydratedLocalPlaybackSong( + index: Int, + originalSong: SongItem, + hydratedSong: SongItem, + currentSongAtRequest: SongItem?, + requestToken: Long +) = runSongMetadataMutation { + val applied = withContext(Dispatchers.Main.immediate) publish@{ + val currentSong = _currentSongFlow.value?.takeIf { it.sameIdentityAs(originalSong) } + if (requestToken != playbackRequestToken || currentSong !== currentSongAtRequest) { + return@publish false } - - runLocalPlaylistMutationSafely("hydratePlaybackSongMetadata") { - withContext(Dispatchers.IO) { - localRepo.updateSongMetadata(song, hydratedSong) - } + // 队列保留同值新快照的引用,明确保存后也不能让旧读取重新覆盖 + val updated = updateCurrentQueueSongs { playlist -> + if (playlist.getOrNull(index) !== originalSong) return@updateCurrentQueueSongs null + playlist.toMutableList().also { it[index] = hydratedSong } + } ?: return@publish false + if (currentSong != null) { + setCurrentSongForPlayback(updated.playlist[index], syncLyricon = false) } - scheduleStatePersist() + true } + if (!applied) return@runSongMetadataMutation + + runLocalPlaylistMutationSafely("hydratePlaybackSongMetadata") { + localRepo.updateSongMetadata(originalSong, hydratedSong) + } + scheduleStatePersist() } internal fun PlayerManager.enterPendingMediaLoad(requestedPositionMs: Long) { diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt index 577b24fd0e..918215bcb7 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt @@ -1894,9 +1894,8 @@ class AudioPlayerService : Service() { } private fun stopForegroundIfStarted(reason: String) { - if (!isForegroundStarted) { - return - } + if (presentationOwnerDelegate.isInitialized()) presentationOwner.stopNotificationUpdates() + if (!isForegroundStarted) return NPLogger.w("NERI-APS", "stopForegroundIfStarted reason=$reason ${buildStateSummary()}") stopForeground(STOP_FOREGROUND_REMOVE) isForegroundStarted = false diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwner.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwner.kt index 35e9bbecd8..8a8b173a01 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwner.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwner.kt @@ -68,6 +68,13 @@ internal class PlaybackArtworkOwner( fun observe(song: SongItem?): PlaybackArtworkSnapshot { val songKey = observedSongKey(song) + if (song != null && hasKnownEmptyLocalArtwork(song)) { + activeSong = song + updateObservedSource(songKey, null) + cancelPendingWork() + clearForNoSong() + return snapshot(songKey) + } val immediateSource = observedImmediateSource(song) val source = resolveMetadataCoverSourceWithRecovery( songKey = songKey, @@ -90,6 +97,10 @@ internal class PlaybackArtworkOwner( return snapshot(songKey) } + private fun hasKnownEmptyLocalArtwork(song: SongItem): Boolean = + song.originalCoverUrl == "" && song.coverUrl.isNullOrBlank() && + song.customCoverUrl.isNullOrBlank() && resolver.isLocal(song) + private fun observedSongKey(song: SongItem?): String? = song?.playbackVisualKey() private fun observedImmediateSource(song: SongItem?): String? = song?.let(resolver::immediate) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackServicePresentationPort.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackServicePresentationPort.kt index 21ddade55f..cdc02fcb3d 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackServicePresentationPort.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackServicePresentationPort.kt @@ -45,6 +45,9 @@ import moe.ouom.neriplayer.util.media.IsLandHelp import moe.ouom.neriplayer.util.media.isShareablePublicHttpUrl import moe.ouom.neriplayer.core.player.service.car.artwork.CarArtworkProvider import moe.ouom.neriplayer.core.player.presentation.widget.PlaybackWidgetState +import java.util.UUID + +private const val MEDIA_NOTIFICATION_PUBLICATION_TOKEN = "moe.ouom.neriplayer.media.publication" internal data class PlaybackServiceNotificationRenderInputs( val song: SongItem?, @@ -86,6 +89,8 @@ internal interface PlaybackServicePresentationPort { fun buildMinimalForegroundNotification(): Notification fun buildNotification(inputs: PlaybackServiceNotificationRenderInputs): Notification fun publishNotification(inputs: PlaybackServiceNotificationRenderInputs) + fun notificationPublicationToken(): String? = null + fun isNotificationPublicationCurrent(token: String): Boolean = false fun hasInstalledWidgets(): Boolean fun publishWidget(state: PlaybackWidgetState, artwork: Bitmap?) fun publishWidgetProgress(state: PlaybackWidgetState) @@ -110,6 +115,9 @@ internal class AndroidPlaybackServicePresentationPort( .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() private var mediaSession: MediaSession? = null + private val notificationGeneration = UUID.randomUUID().toString() + private var notificationSequence = 0L + private var lastNotificationPublicationToken: String? = null override fun initializeSession(callback: MediaSession.Callback) { mediaSession = MediaSession(context, "NeriPlayerSession").apply { @@ -182,7 +190,19 @@ internal class AndroidPlaybackServicePresentationPort( override fun publishNotification(inputs: PlaybackServiceNotificationRenderInputs) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(AudioPlayerService.NOTIFICATION_ID, buildNotification(inputs)) + val notification = buildNotification(inputs) + manager.notify(AudioPlayerService.NOTIFICATION_ID, notification) + lastNotificationPublicationToken = notification.extras.getString(MEDIA_NOTIFICATION_PUBLICATION_TOKEN) + } + + override fun notificationPublicationToken(): String? = lastNotificationPublicationToken + + override fun isNotificationPublicationCurrent(token: String): Boolean { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + return manager.activeNotifications.any { + it.id == AudioPlayerService.NOTIFICATION_ID && it.tag == null && + it.notification.extras.getString(MEDIA_NOTIFICATION_PUBLICATION_TOKEN) == token + } } override fun hasInstalledWidgets(): Boolean = PlayerDependencies.presentation.hasInstalledWidgets(context) @@ -241,7 +261,10 @@ internal class AndroidPlaybackServicePresentationPort( addMediaNotificationActions(builder, inputs, contentIntent) applyMediaNotificationText(builder, inputs) applyMediaNotificationArtwork(builder, inputs.artwork) - return finishNotification(builder, inputs) + return finishNotification(builder, inputs).also { notification -> + notification.extras.putString(MEDIA_NOTIFICATION_PUBLICATION_TOKEN, + "$notificationGeneration:${++notificationSequence}") + } } private fun applyMediaNotificationText( diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationPublicationOwner.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationPublicationOwner.kt new file mode 100644 index 0000000000..5af442c76b --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationPublicationOwner.kt @@ -0,0 +1,94 @@ +package moe.ouom.neriplayer.core.player.service.presentation + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import moe.ouom.neriplayer.common.logging.NPLogger + +private const val MEDIA_NOTIFICATION_WINDOW_MS = 500L +private const val MEDIA_NOTIFICATION_CONFIRMATION_MS = 1_000L +private const val MEDIA_NOTIFICATION_MAX_RETRIES = 2 + +private data class PendingPlaybackNotification( + val snapshot: PlaybackNotificationSnapshot, + val inputs: PlaybackServiceNotificationRenderInputs, +) + +internal class PlaybackNotificationPublicationOwner( + private val scope: CoroutineScope, + private val port: PlaybackServicePresentationPort, + private val prepareInputs: (PlaybackServiceNotificationRenderInputs) -> PlaybackServiceNotificationRenderInputs, +) { + private var latest: PendingPlaybackNotification? = null + private var pending: PendingPlaybackNotification? = null + private var windowJob: Job? = null + private var confirmationJob: Job? = null + + fun publish(snapshot: PlaybackNotificationSnapshot, inputs: PlaybackServiceNotificationRenderInputs, force: Boolean) { + if (!force && latest?.snapshot == snapshot) return + confirmationJob?.cancel() + confirmationJob = null + val publication = PendingPlaybackNotification(snapshot, inputs) + latest = publication + if (windowJob?.isActive == true) { + pending = publication + } else { + send(publication, retry = 0) + } + } + + fun cancel() { + latest = null + pending = null + windowJob?.cancel() + windowJob = null + confirmationJob?.cancel() + confirmationJob = null + } + + private fun send(publication: PendingPlaybackNotification, retry: Int) { + if (latest !== publication) return + val published = try { + port.publishNotification(prepareInputs(publication.inputs)) + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + NPLogger.w("NERI-APS", "media notification publication failed", failure) + false + } + windowJob = scope.launch { + delay(MEDIA_NOTIFICATION_WINDOW_MS) + windowJob = null + val next = pending + pending = null + if (next != null) send(next, retry = 0) + } + val token = if (published) port.notificationPublicationToken() else null + if (published && token == null) return + confirmationJob = scope.launch { + delay(MEDIA_NOTIFICATION_CONFIRMATION_MS * (retry + 1)) + if (latest !== publication || isConfirmed(token)) return@launch + confirmationJob = null + if (retry < MEDIA_NOTIFICATION_MAX_RETRIES) { + send(publication, retry + 1) + } else { + NPLogger.w("NERI-APS", "media notification remains unconfirmed after bounded retries") + } + } + } + + private fun isConfirmed(token: String?): Boolean { + if (token == null) return false + return try { + port.isNotificationPublicationCurrent(token) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + NPLogger.w("NERI-APS", "media notification confirmation failed", failure) + false + } + } +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwner.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwner.kt index 93090c92cf..79796a3be2 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwner.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwner.kt @@ -124,7 +124,7 @@ internal class PlaybackServicePresentationOwner( private val scope: CoroutineScope, ) { private val playbackStateThrottler = MediaSessionPlaybackStateThrottler() - private var lastNotificationSnapshot: PlaybackNotificationSnapshot? = null + private val notificationPublication = PlaybackNotificationPublicationOwner(scope, port, ::withShareUrl) private var lastMetadataSnapshot: PlaybackMetadataSnapshot? = null // 会话拒收过位图的封面, 同一封面后续只发布 URI, 避免每行歌词都重复失败的 Binder 事务 private var rejectedArtworkCoverSource: String? = null @@ -155,9 +155,17 @@ internal class PlaybackServicePresentationOwner( fun audioAttributes(): AudioAttributes = port.audioAttributes() - fun releaseSessionAfterForegroundFailure(reason: String) = port.releaseSessionAfterForegroundFailure(reason) + fun releaseSessionAfterForegroundFailure(reason: String) { + stopNotificationUpdates() + port.releaseSessionAfterForegroundFailure(reason) + } + + fun releaseSessionForDestroy() { + stopNotificationUpdates() + port.releaseSessionForDestroy() + } - fun releaseSessionForDestroy() = port.releaseSessionForDestroy() + fun stopNotificationUpdates() = notificationPublication.cancel() fun buildBootstrapNotification(): Notification = port.buildBootstrapNotification() @@ -206,12 +214,13 @@ internal class PlaybackServicePresentationOwner( lyricState: StatusBarLyricNotificationState, floatingLyricsEnabled: Boolean, ) { - if (!foregroundStarted) return + if (!foregroundStarted) { + stopNotificationUpdates() + return + } val prepared = prepareNotification(lyricState, floatingLyricsEnabled) updateWidget(force = false, floatingLyricsEnabled = floatingLyricsEnabled) - if (!force && prepared.snapshot == lastNotificationSnapshot) return - lastNotificationSnapshot = prepared.snapshot - port.publishNotification(withShareUrl(prepared.renderInputs)) + notificationPublication.publish(prepared.snapshot, prepared.renderInputs, force) } private fun withShareUrl(inputs: PlaybackServiceNotificationRenderInputs): PlaybackServiceNotificationRenderInputs = diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt index d6232b68fd..a8257e3071 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt @@ -357,6 +357,34 @@ class PlayerManagerCustomMetadataNormalizationTest { assertTrue(shouldSkipSongMetadataMutation(song, song, writeLyrics = false)) assertFalse(shouldSkipSongMetadataMutation(song, song, writeLyrics = true)) + assertFalse(shouldSkipSongMetadataMutation(song, song, writeLyrics = false, writeLocalMetadata = true)) + } + + @Test + fun `local cover lookup stops before later audio probes after a usable source`() { + var laterProbeCount = 0 + + val cover = firstUsableLocalCoverReference( + { "file:///selected-cover.png" }, + { laterProbeCount++; null } + ) + + assertEquals("file:///selected-cover.png", cover) + assertEquals(0, laterProbeCount) + } + + @Test + fun `local cover lookup skips remote and media store album artwork`() { + var acceptedProbeCount = 0 + + val cover = firstUsableLocalCoverReference( + { "https://example.com/remote.jpg" }, + { "content://media/external/audio/albumart/7" }, + { acceptedProbeCount++; "file:///original.png" } + ) + + assertEquals("file:///original.png", cover) + assertEquals(1, acceptedProbeCount) } @Test diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyricsTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyricsTest.kt new file mode 100644 index 0000000000..db0e20ad5b --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/SelectedSongMetadataLyricsTest.kt @@ -0,0 +1,131 @@ +package moe.ouom.neriplayer.core.player.metadata + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchCandidate +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchConfidence +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.lyrics.matching.RankedEditableLyricMatch +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongSearchInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SelectedSongMetadataLyricsTest { + private val song = SongItem(7L, "Original", "Artist", "__local_files__", 0L, 120_000L, null, + matchedLyric = "kept original", matchedTranslatedLyric = "kept translation", + matchedRomanizedLyric = "", matchedSongId = "previous", matchedLyricSource = MusicPlatform.QQ_MUSIC, + lyricSyncEdited = true, lyricSyncRevision = 10L) + private val selected = SongSearchInfo("42", "Selected", "Singer", "02:00", MusicPlatform.CLOUD_MUSIC, null, null) + private val details = selected.toBasicSongDetails() + + @Test + fun `no selected lyric tracks or sources skips all lyric network calls`() = runTest { + for (options in listOf( + SongMetadataApplyOptions(originalLyrics = false, translatedLyrics = false, romanizedLyrics = false), + SongMetadataApplyOptions(lyricSources = emptySet()) + )) { + val result = fetchSelectedSongMetadataLyrics(song, selected, details, options, + matchLyrics = { error("matcher must not run") }, + loadRomanized = { error("romanization must not run") }) + + assertFalse(result.hasUpdates) + } + } + + @Test + fun `local songs use selected sources directly and keep disabled tracks`() = runTest { + val result = fetchSelectedSongMetadataLyrics(song, selected, details, + SongMetadataApplyOptions( + lyricSources = setOf(EditableLyricMatchSource.KUGOU), + originalLyrics = false, translatedLyrics = true, romanizedLyrics = false, + preferWordTimed = false + ), + matchLyrics = { request -> + assertEquals(setOf(EditableLyricMatchSource.KUGOU), request.sources) + assertEquals("Selected", request.trackName) + assertFalse(request.preferWordTimed) + listOf(match(EditableLyricMatchSource.KUGOU)) + }, loadRomanized = { error("disabled romanization must not run") }) + + val updated = applySelectedSongMetadata(song, selected, details, null, result, true, 11L, false) + + assertEquals(song.matchedLyric, updated.matchedLyric) + assertEquals("[00:01.00]translation", updated.matchedTranslatedLyric) + assertEquals("", updated.matchedRomanizedLyric) + assertEquals("Selected", updated.customName) + assertNull(updated.matchedLyricSource) + assertNull(updated.matchedSongId) + assertEquals(11L, updated.lyricSyncRevision) + } + + @Test + fun `unselected sources cannot leak detail lyrics or romanization into the result`() = runTest { + val result = fetchSelectedSongMetadataLyrics(song, selected, + details.copy(lyric = "[00:01.00]unselected detail lyric"), + SongMetadataApplyOptions(lyricSources = setOf(EditableLyricMatchSource.KUGOU)), + matchLyrics = { listOf(match(EditableLyricMatchSource.CLOUD_MUSIC)) }, + loadRomanized = { error("unselected source must not run") }) + + assertFalse(result.hasUpdates) + } + + @Test + fun `mismatched candidate identity or duration leaves stored tracks unchanged`() = runTest { + val valid = match(EditableLyricMatchSource.KUGOU) + for (invalid in listOf( + valid.copy(candidate = valid.candidate.copy(title = "Other track")), + valid.copy(candidate = valid.candidate.copy(durationMs = 240_000L)) + )) { + val result = fetchSelectedSongMetadataLyrics(song, selected, details, + SongMetadataApplyOptions(lyricSources = setOf(EditableLyricMatchSource.KUGOU)), + matchLyrics = { listOf(invalid) }, loadRomanized = { emptyList() }) + val updated = applySelectedSongMetadata(song, selected, details, null, result, true, 11L, false) + + assertFalse(result.hasUpdates) + assertEquals(song.matchedLyric, updated.matchedLyric) + assertEquals(song.matchedTranslatedLyric, updated.matchedTranslatedLyric) + assertEquals(song.matchedRomanizedLyric, updated.matchedRomanizedLyric) + assertEquals(song.matchedSongId, updated.matchedSongId) + assertEquals(song.lyricSyncRevision, updated.lyricSyncRevision) + } + } + + @Test + fun `word timed selected lyrics retain their syllable timing`() = runTest { + val candidate = match(EditableLyricMatchSource.KUGOU).let { + it.copy(candidate = it.candidate.copy(lyrics = "[1000,2000](1000,1000,0)hello(2000,1000,0) world")) + } + val result = fetchSelectedSongMetadataLyrics(song, selected, details, + SongMetadataApplyOptions(lyricSources = setOf(EditableLyricMatchSource.KUGOU), romanizedLyrics = false), + matchLyrics = { listOf(candidate) }, loadRomanized = { emptyList() }) + + assertTrue(requireNotNull(result.original).contains("(1000,1000,0)hello")) + assertTrue(requireNotNull(result.original).contains("(2000,1000,0) world")) + } + + @Test + fun `lyric request cancellation propagates before a prepared result exists`() = runTest { + val cancellation = CancellationException("cancelled selected lyrics") + + val failure = runCatching { + fetchSelectedSongMetadataLyrics(song, selected, details, SongMetadataApplyOptions(), + matchLyrics = { throw cancellation }, loadRomanized = { error("must not run") }) + }.exceptionOrNull() + + assertTrue(failure is CancellationException) + assertEquals(cancellation.message, failure?.message) + } + + private fun match(source: EditableLyricMatchSource) = RankedEditableLyricMatch( + candidate = EditableLyricMatchCandidate( + id = "42", source = source, title = "Selected", artist = "Singer", durationMs = 120_000L, + lyrics = "[00:01.00]new original", translatedLyrics = "[00:01.00]translation" + ), + score = 100, durationDeltaMs = 0L, confidence = EditableLyricMatchConfidence.HIGH + ) +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt index e1c160f0ff..a8a215b2da 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt @@ -14,12 +14,28 @@ import android.system.Os import android.system.StructStat import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withContext import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyOptions +import moe.ouom.neriplayer.core.player.metadata.SongMetadataApplyStage +import moe.ouom.neriplayer.core.player.metadata.ExternalBluetoothLyricPayload +import moe.ouom.neriplayer.core.player.service.notification.serviceNotificationTitle +import moe.ouom.neriplayer.core.player.service.presentation.serviceMetadataText import moe.ouom.neriplayer.core.player.host.PlayerDependencies import moe.ouom.neriplayer.core.player.host.PlayerDownloadAccess import moe.ouom.neriplayer.core.player.host.PlayerEnvironment @@ -31,6 +47,10 @@ import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager import moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspector import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome +import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata +import moe.ouom.neriplayer.data.local.media.displayArtist +import moe.ouom.neriplayer.data.local.media.displayCoverUrl +import moe.ouom.neriplayer.data.local.media.displayName import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess @@ -38,8 +58,18 @@ import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchCandidate +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchConfidence +import moe.ouom.neriplayer.data.model.lyrics.matching.EditableLyricMatchSource +import moe.ouom.neriplayer.data.model.lyrics.matching.RankedEditableLyricMatch +import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.music.SongDetails +import moe.ouom.neriplayer.data.model.music.SongSearchInfo import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist import moe.ouom.neriplayer.data.playlist.usage.PlaylistUsageRepository +import moe.ouom.neriplayer.data.stats.PlaybackStatsRepository +import moe.ouom.neriplayer.platform.lyrics.repository.EditableLyricsMatcher +import moe.ouom.neriplayer.platform.search.api.client.CloudMusicSearchApi import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals @@ -60,6 +90,7 @@ import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.mockingDetails import org.mockito.Mockito.spy import org.mockito.Mockito.`when` import org.mockito.stubbing.Answer @@ -70,6 +101,8 @@ import java.io.File import java.io.FileDescriptor import java.net.URI import java.util.IdentityHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.coroutines.CoroutineContext @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -94,16 +127,18 @@ class PlayerManagerLocalMetadataEditRegressionTest { .getDeclaredField("generation").apply { isAccessible = true } private val previousPersistenceGeneration = persistenceGenerationField.get(manager.statePersistenceCoordinator) private lateinit var application: Application + private lateinit var repositories: PlayerRepositoryDependencies private var lyricProbeFailure: RuntimeException? = null private var lyricProbeCount = 0 private var denyLocalPlayback = false + private var managedDownloadCached = false private var editSourceContext: ResolvedLocalMediaSource? = null @Before fun setUp() { application = spy(ApplicationProvider.getApplicationContext()) doReturn(temporaryFolder.root).`when`(application).filesDir - val repositories = mock(PlayerRepositoryDependencies::class.java) + repositories = mock(PlayerRepositoryDependencies::class.java) `when`(repositories.playHistoryRepo).thenReturn(mock(PlayHistoryRepository::class.java)) `when`(repositories.playlistUsageRepo).thenReturn(mock(PlaylistUsageRepository::class.java)) val downloads = mock(PlayerDownloadAccess::class.java, Answer { invocation -> @@ -113,6 +148,7 @@ class PlayerManagerLocalMetadataEditRegressionTest { if (denyLocalPlayback) null else invocation.getArgument(2) } "downloadedSongs" -> emptyList() + "hasDownloadedSongCached" -> managedDownloadCached else -> Answers.RETURNS_DEFAULTS.answer(invocation) } }) @@ -204,6 +240,538 @@ class PlayerManagerLocalMetadataEditRegressionTest { assertNull(sourceContext.anchorFor(song.copy(id = 8L), sourceContext.reference)) } + @Test + fun `unchanged app-only title and artist edits still reach an explicitly requested file write`() = runTest { + val audio = temporaryFolder.newFile("retry-title.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + withRegularFileDescriptors { + assertTrue(manager.updateSongCustomInfoImpl(song, null, "Edited song", "Edited artist")) + } + val edited = manager.currentPlaylist.single() + + withSuccessfulAssetWrite { writes -> + repeat(2) { + assertTrue(manager.updateSongCustomInfoImpl( + edited, null, "Edited song", "Edited artist", writeLocalMetadata = true + )) + } + + assertEquals(2, writes.size) + writes.forEach { written -> + assertEquals("Edited song", written.customName) + assertEquals("Edited artist", written.customArtist) + assertEquals("song", written.originalName) + assertEquals("artist", written.originalArtist) + } + } + } + + @Test + fun `title and artist edits do not inspect untouched local lyrics`() = runTest { + val audio = temporaryFolder.newFile("title-without-lyric-probe.mp3").apply { writeText("audio") } + val song = localSong(audio.path) + manager.publishCurrentQueue(listOf(song), 0) + + withRegularFileDescriptors { + assertTrue(manager.updateSongCustomInfoImpl(song, null, "Edited title", "Edited artist")) + } + + assertEquals(0, lyricProbeCount) + assertNull(manager.currentPlaylist.single().originalLyric) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `first restore stays visible after an older local playback hydration finishes`() = runTest { + val playbackStats = mock(PlaybackStatsRepository::class.java) + `when`(playbackStats.statsClearedAtFlow).thenReturn(MutableStateFlow(0L)) + `when`(repositories.playbackStatsRepo).thenReturn(playbackStats) + val audio = temporaryFolder.newFile("late-playback-hydration.mp3").apply { writeText("audio") } + val originalCover = cover("original-playback.png", Color.RED).toURI().toString() + val editedCover = cover("edited-playback.png", Color.BLUE).toURI().toString() + val originalLyric = "[00:01.00]original lyric" + val song = localSong(audio.path).copy( + name = "Original title", artist = "Original artist", coverUrl = originalCover, + customName = "Edited title", customArtist = "Edited artist", customCoverUrl = editedCover, + originalName = "Original title", originalArtist = "Original artist", originalCoverUrl = originalCover, + matchedLyric = "[00:01.00]edited lyric", matchedTranslatedLyric = "edited translation", + matchedRomanizedLyric = "edited romanization", originalLyric = originalLyric, + originalTranslatedLyric = "original translation", originalRomanizedLyric = "original romanization", + lyricSyncEdited = true + ) + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("name", song.name).put("artist", song.artist).put("coverUrl", originalCover) + .put("customName", song.customName).put("customArtist", song.customArtist) + .put("customCoverUrl", editedCover).put("coverPath", editedCover) + .put("originalName", song.originalName).put("originalArtist", song.originalArtist) + .put("originalCoverUrl", originalCover).put("originalLyric", originalLyric) + .put("originalTranslatedLyric", song.originalTranslatedLyric) + .put("originalRomanizedLyric", song.originalRomanizedLyric) + .put("matchedLyric", song.matchedLyric).put("matchedTranslatedLyric", song.matchedTranslatedLyric) + .put("matchedRomanizedLyric", song.matchedRomanizedLyric).put("sourceModifiedAtMs", 123L) + .toString()) + manager.publishCurrentQueue(listOf(song), 0) + manager._currentSongFlow.value = song + managedDownloadCached = true + val main = HeldPlaybackMainDispatcher() + val hydrationLifetime = SupervisorJob() + val previousIoScope = manager.ioScope + manager.ioScope = CoroutineScope(hydrationLifetime + Dispatchers.IO + CoroutineExceptionHandler { _, failure -> + main.publicationQueued.completeExceptionally(failure) + }) + Dispatchers.setMain(main) + try { + val playback = Class.forName("moe.ouom.neriplayer.core.player.playback.PlayerManagerPlaybackExtensionsKt") + playback.getDeclaredMethod( + "maybeHydrateSongForPlayback", PlayerManager::class.java, + Int::class.javaPrimitiveType, SongItem::class.java, Long::class.javaPrimitiveType + ).apply { isAccessible = true }.invoke(null, manager, 0, song, manager.playbackRequestToken) + main.publicationQueued.await() + val hydration = hydrationLifetime.children.single() + + withRegularFileDescriptors { + assertTrue(manager.updateSongLyricsAndTranslationImpl( + song, originalLyric, song.originalTranslatedLyric, song.originalRomanizedLyric, + persistLocalSidecars = true, syncDownloadedMetadata = false + )) + assertTrue(manager.updateSongCustomInfoImpl( + manager.currentPlaylist.single(), originalCover, song.originalName, song.originalArtist, + restoreBaseCover = true, restoreBaseName = true, restoreBaseArtist = true, + restoreBaseLyrics = true, clearMatchedMetadata = true + )) + } + val expected = listOf( + "Original title", "Original artist", originalCover, originalLyric, + "original translation", "original romanization", "Original title", "Original artist" + ) + assertEquals(expected, playbackDisplayFields(requireNotNull(manager._currentSongFlow.value))) + assertEquals("Original title", LocalMediaSupport.readLocalMetadataSidecarFast( + application, manager.currentPlaylist.single() + )?.name) + + main.release() + hydration.join() + + assertEquals(expected, playbackDisplayFields(requireNotNull(manager._currentSongFlow.value))) + assertEquals(expected, playbackDisplayFields(manager.currentPlaylist.single())) + } finally { + hydrationLifetime.cancel() + main.release() + hydrationLifetime.join() + manager.ioScope = previousIoScope + Dispatchers.resetMain() + } + } + + private fun playbackDisplayFields(song: SongItem): List = listOf( + song.displayName(), song.displayArtist(), song.displayCoverUrl(), song.matchedLyric, + song.matchedTranslatedLyric, song.matchedRomanizedLyric, serviceNotificationTitle(song), + serviceMetadataText(song, ExternalBluetoothLyricPayload(), null, false).artist + ) + + private class HeldPlaybackMainDispatcher : CoroutineDispatcher() { + val publicationQueued = CompletableDeferred() + private val pending = ConcurrentLinkedQueue() + @Volatile private var released = false + + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (released) { + block.run() + return + } + pending.add(block) + publicationQueued.complete(Unit) + } + + fun release() { + released = true + while (true) (pending.poll() ?: return).run() + } + } + + @Test + fun `local playback hydration still publishes untouched metadata`() = runTest { + val song = songAwaitingPlaybackHydration("untouched") + withHeldLocalPlaybackHydration(song) { main, hydration -> + main.release() + hydration.join() + + assertEquals("Hydrated title", manager._currentSongFlow.value?.displayName()) + assertEquals("Hydrated artist", manager.currentPlaylist.single().displayArtist()) + assertEquals(1, metadataRepositoryWrites()) + } + } + + @Test + fun `local playback hydration skips a superseded playback request`() = runTest { + val song = songAwaitingPlaybackHydration("superseded") + val previousToken = manager.playbackRequestToken + try { + withHeldLocalPlaybackHydration(song) { main, hydration -> + manager.playbackRequestToken++ + main.release() + hydration.join() + + assertEquals(song, manager._currentSongFlow.value) + assertEquals(song, manager.currentPlaylist.single()) + assertEquals(0, metadataRepositoryWrites()) + } + } finally { + manager.playbackRequestToken = previousToken + } + } + + @Test + fun `same value explicit file write invalidates an older local playback hydration`() = runTest { + val song = songAwaitingPlaybackHydration("same-value") + withHeldLocalPlaybackHydration(song) { main, hydration -> + withSuccessfulAssetWrite { + assertTrue(manager.updateSongCustomInfoImpl(song, null, null, null, writeLocalMetadata = true)) + } + assertEquals(song, manager.currentPlaylist.single()) + assertTrue(song !== manager.currentPlaylist.single()) + main.release() + hydration.join() + + assertEquals(song, manager._currentSongFlow.value) + assertEquals(song, manager.currentPlaylist.single()) + assertEquals(1, metadataRepositoryWrites()) + } + } + + @Test + fun `queued external metadata hydration cannot overwrite a later restored playback song`() = runTest { + val audio = temporaryFolder.newFile("external-stale.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + customName = "Edited title", originalName = "song", originalArtist = "artist", + originalCoverUrl = "", originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + withHeldExternalHydration(song, song.copy(name = "Stale title", customName = "Edited title")) { pending, hydration -> + withRegularFileDescriptors { + assertTrue(manager.updateSongCustomInfoImpl( + song, null, "song", "artist", restoreBaseName = true, restoreBaseArtist = true + )) + } + val restored = manager.currentPlaylist.single() + assertEquals("song", restored.displayName()) + pending.release() + hydration.join() + + assertEquals(restored, manager._currentSongFlow.value) + assertEquals(restored, manager.currentPlaylist.single()) + assertEquals(1, metadataRepositoryWrites()) + } + } + + @Test + fun `queued external metadata hydration accepts an unchanged equal playback snapshot`() = runTest { + val song = localSong("/fixture/external.mp3") + val hydrated = song.copy(name = "Hydrated external") + withHeldExternalHydration(song, hydrated, requestSong = song.copy()) { pending, hydration -> + pending.release() + hydration.join() + + assertEquals(hydrated, manager._currentSongFlow.value) + assertEquals(hydrated, manager.currentPlaylist.single()) + assertEquals(1, metadataRepositoryWrites()) + } + } + + @Test + fun `external metadata hydration still persists a song outside the playback queue`() = runTest { + val song = localSong("/fixture/off-queue.mp3") + val hydrated = song.copy(name = "Hydrated catalog title") + withHeldExternalHydration(song, hydrated, inPlayback = false) { pending, hydration -> + pending.release() + hydration.join() + + assertTrue(manager.currentPlaylist.isEmpty()) + assertNull(manager._currentSongFlow.value) + val update = mockingDetails(repositoryField.get(null)).invocations + .single { it.method.name == "updateSongMetadata" } + assertEquals(listOf(song, hydrated), update.arguments.take(2)) + } + } + + private fun songAwaitingPlaybackHydration(fileName: String): SongItem { + val audio = temporaryFolder.newFile("$fileName.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + originalName = "song", originalArtist = "artist", originalCoverUrl = "", + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("name", "Hydrated title").put("artist", "Hydrated artist") + .put("originalName", "song").put("originalArtist", "artist") + .put("originalCoverUrl", "").put("originalLyric", "") + .put("originalTranslatedLyric", "").put("originalRomanizedLyric", "") + .put("sourceModifiedAtMs", 123L).toString()) + return song + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun withHeldLocalPlaybackHydration( + song: SongItem, + block: suspend (HeldPlaybackMainDispatcher, Job) -> Unit + ) { + configurePlaybackStats() + manager.publishCurrentQueue(listOf(song), 0) + manager._currentSongFlow.value = song + managedDownloadCached = true + val main = HeldPlaybackMainDispatcher() + val lifetime = SupervisorJob() + val previousScope = manager.ioScope + manager.ioScope = CoroutineScope(lifetime + Dispatchers.IO + CoroutineExceptionHandler { _, failure -> + main.publicationQueued.completeExceptionally(failure) + }) + Dispatchers.setMain(main) + try { + Class.forName("moe.ouom.neriplayer.core.player.playback.PlayerManagerPlaybackExtensionsKt") + .getDeclaredMethod("maybeHydrateSongForPlayback", PlayerManager::class.java, + Int::class.javaPrimitiveType, SongItem::class.java, Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(null, manager, 0, song, manager.playbackRequestToken) + main.publicationQueued.await() + block(main, lifetime.children.single()) + } finally { + lifetime.cancel() + main.release() + lifetime.join() + manager.ioScope = previousScope + Dispatchers.resetMain() + } + } + + private suspend fun withHeldExternalHydration( + song: SongItem, + hydrated: SongItem, + requestSong: SongItem = song, + inPlayback: Boolean = true, + block: suspend (HeldPlaybackMainDispatcher, Job) -> Unit + ) { + configurePlaybackStats() + manager.publishCurrentQueue(if (inPlayback) listOf(song) else emptyList(), if (inPlayback) 0 else -1) + manager._currentSongFlow.value = song.takeIf { inPlayback } + val dispatcher = HeldPlaybackMainDispatcher() + val lifetime = SupervisorJob() + val previousScope = manager.ioScope + manager.ioScope = CoroutineScope(lifetime + dispatcher) + try { + manager.hydrateSongMetadataImpl(requestSong, hydrated) + block(dispatcher, lifetime.children.single()) + } finally { + lifetime.cancel() + dispatcher.release() + lifetime.join() + manager.ioScope = previousScope + } + } + + private fun configurePlaybackStats() { + val stats = mock(PlaybackStatsRepository::class.java) + `when`(stats.statsClearedAtFlow).thenReturn(MutableStateFlow(0L)) + `when`(repositories.playbackStatsRepo).thenReturn(stats) + } + + private fun metadataRepositoryWrites(): Int = mockingDetails(repositoryField.get(null)).invocations + .count { it.method.name == "updateSongMetadata" } + + @Test + fun `unchanged app-only cover is included in a later explicit file write without replacing its baseline`() = runTest { + val audio = temporaryFolder.newFile("retry-cover.mp3").apply { writeText("audio") } + val selectedCover = cover("retry-selected.png") + val reference = selectedCover.toURI().toString() + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + withConfirmedCoverAbsence(true) { + assertTrue(manager.updateSongCustomInfoImpl(song, reference, null, null)) + } + val edited = manager.currentPlaylist.single() + assertEquals("", edited.originalCoverUrl) + val coverWrites = mutableListOf>() + + withSuccessfulAssetWrite(onWrite = { _, coverReference, writeCover, _ -> + coverWrites += coverReference to writeCover + }) { writes -> + assertTrue(manager.updateSongCustomInfoImpl( + edited, reference, "Another title", null, writeLocalMetadata = true + )) + + assertEquals(listOf(reference to true), coverWrites) + assertEquals("", writes.single().originalCoverUrl) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + } + } + + @Test + fun `repeated explicit cover restoration writes the absent original cover again`() = runTest { + val audio = temporaryFolder.newFile("retry-cover-restore.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + originalName = "song", originalArtist = "artist", originalCoverUrl = "", + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + val coverWrites = mutableListOf>() + + withSuccessfulAssetWrite(onWrite = { _, coverReference, writeCover, _ -> + coverWrites += coverReference to writeCover + }) { writes -> + repeat(2) { + assertTrue(manager.updateSongCustomInfoImpl( + song, null, null, null, restoreBaseCover = true, writeLocalMetadata = true + )) + } + + assertEquals(2, writes.size) + assertEquals(listOf(null to true, null to true), coverWrites) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + } + } + + @Test + fun `ordinary write after reopening an app-only no-artwork restoration clears the embedded cover`() = runTest { + val audio = temporaryFolder.newFile("reopened-empty-cover.mp3").apply { writeText("audio") } + val selectedCover = cover("reopened-selected.png").toURI().toString() + val song = localSong(audio.path).copy( + originalName = "song", originalArtist = "artist", originalCoverUrl = "", + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + withSuccessfulAssetWrite { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + song, LocalSongAssetEmbedding(coverReference = selectedCover) + )) + } + withRegularFileDescriptors { + assertTrue(manager.updateSongCustomInfoImpl( + manager.currentPlaylist.single(), null, null, null, restoreBaseCover = true + )) + } + val reopened = LocalAudioImportManager.hydrateLocalSongCoverMetadata(application, song) + assertEquals("", reopened.originalCoverUrl) + assertNull(reopened.customCoverUrl) + assertNull(reopened.coverUrl) + manager.publishCurrentQueue(listOf(reopened), 0) + val coverWrites = mutableListOf>() + + withSuccessfulAssetWrite(onWrite = { _, reference, writesCover, _ -> + coverWrites += reference to writesCover + }) { writes -> + assertTrue(manager.updateSongCustomInfoImpl( + reopened, null, null, null, writeLocalMetadata = true + )) + + assertEquals(1, writes.size) + assertEquals(listOf(null to true), coverWrites) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + } + } + + @Test + fun `ordinary explicit writes do not clear unknown artwork or an existing displayed base cover`() = runTest { + val audio = temporaryFolder.newFile("unknown-or-visible-cover.mp3").apply { writeText("audio") } + val existingCover = cover("existing-base.png").toURI().toString() + for (song in listOf( + localSong(audio.path), + localSong(audio.path).copy(originalCoverUrl = "", coverUrl = existingCover) + )) { + manager.publishCurrentQueue(listOf(song), 0) + val coverWrites = mutableListOf>() + withSuccessfulAssetWrite(onWrite = { _, reference, writesCover, _ -> + coverWrites += reference to writesCover + }) { + assertTrue(manager.updateSongCustomInfoImpl(song, null, null, null, writeLocalMetadata = true)) + + assertEquals(listOf(null to false), coverWrites) + } + } + } + + @Test + fun `explicit metadata writes recover unread local lyric baselines without inventing missing tracks`() = runTest { + val audio = temporaryFolder.newFile("write-existing-lyrics.mp3").apply { writeText("audio") } + val originalLyric = "[00:01.00]existing local lyric" + File(audio.parentFile, "write-existing-lyrics.lrc").writeText(originalLyric) + val song = localSong(audio.path) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { writes -> + assertTrue(manager.updateSongCustomInfoImpl( + song, null, "New title", null, writeLocalMetadata = true, writeLyrics = true + )) + + assertEquals(originalLyric, writes.single().originalLyric) + assertNull(writes.single().matchedLyric) + assertNull(writes.single().originalTranslatedLyric) + assertNull(writes.single().originalRomanizedLyric) + } + } + + @Test + fun `local write wrapper preserves unknown physical lyric tracks without promoting them to originals`() = runTest { + val audio = temporaryFolder.newFile("preserve-unknown-physical-lyrics.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + matchedLyric = "edited original", originalLyric = "first original", lyricSyncEdited = true + ) + val inspected = LocalLyricsScanMetadata( + lyric = "physical original", translatedLyric = "physical translation", romanizedLyric = null, + embeddedSourceResolved = true + ) + + withCapturedPlatformWrite(inspected) { writes, inspectionCount -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.writeLocalEditableMetadata( + song = song, writeCover = false, writeLyrics = true + )) + + assertEquals(1, inspectionCount()) + assertEquals("edited original", writes.single().matchedLyric) + assertEquals("physical translation", writes.single().matchedTranslatedLyric) + assertEquals("", writes.single().matchedRomanizedLyric) + assertEquals("first original", writes.single().originalLyric) + assertNull(writes.single().originalTranslatedLyric) + assertNull(writes.single().originalRomanizedLyric) + } + } + + @Test + fun `local write wrapper refuses to clear unknown lyric tracks when their physical read fails`() = runTest { + val audio = temporaryFolder.newFile("unreadable-physical-lyrics.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy(matchedLyric = "edited original", lyricSyncEdited = true) + + for (inspection in listOf(null, LocalLyricsScanMetadata(null, null, null))) { + withCapturedPlatformWrite(inspection) { writes, inspectionCount -> + assertEquals(LocalMediaMetadataWriteOutcome.FAILED, manager.writeLocalEditableMetadata( + song = song, writeCover = false, writeLyrics = true + )) + + assertEquals(1, inspectionCount()) + assertTrue(writes.isEmpty()) + assertEquals("audio", audio.readText()) + } + } + } + + @Test + fun `local write wrapper does not inspect fully known lyrics or alter explicit empty tracks`() = runTest { + val audio = temporaryFolder.newFile("known-physical-lyrics.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + matchedLyric = "edited original", matchedTranslatedLyric = "", originalRomanizedLyric = "" + ) + + withCapturedPlatformWrite(null) { writes, inspectionCount -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.writeLocalEditableMetadata( + song = song, writeCover = false, writeLyrics = true + )) + + assertEquals(0, inspectionCount()) + assertEquals("", writes.single().matchedTranslatedLyric) + assertEquals("", writes.single().originalRomanizedLyric) + } + } + @Test fun `later cover replacement preserves a no-artwork baseline loaded from metadata`() = runTest { val audio = temporaryFolder.newFile("song.mp3").apply { writeText("audio") } @@ -233,6 +801,171 @@ class PlayerManagerLocalMetadataEditRegressionTest { assertEquals("", JSONObject(File(audio.path + ".npmeta.json").readText()).getString("originalCoverUrl")) } + @Test + fun `manual acquisition writes selected lyrics and metadata in one file transaction`() = runTest { + val audio = temporaryFolder.newFile("manual-one-write.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + durationMs = 120_000L, + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + val selected = configureManualSelection() + manager.publishCurrentQueue(listOf(song), 0) + val stages = mutableListOf() + val lyricWrites = mutableListOf() + + withSuccessfulAssetWrite(onWrite = { _, _, _, writeLyrics -> lyricWrites += writeLyrics }) { writes -> + assertTrue(manager.applyManualMetadataSelection( + song, selected, + SongMetadataApplyOptions( + lyricSources = setOf(EditableLyricMatchSource.KUGOU), + romanizedLyrics = false, writeLocalMetadata = true + ), + isLatestRequest = { true }, onProgress = { stages += it } + )) + + assertEquals(1, writes.size) + assertEquals(listOf(true), lyricWrites) + assertEquals("Selected", writes.single().customName) + assertEquals("[00:01.00]selected lyric", writes.single().matchedLyric) + assertEquals("[00:01.00]selected translation", writes.single().matchedTranslatedLyric) + assertEquals(SongMetadataApplyStage.entries.toList(), stages) + assertEquals(writes.single(), manager.currentPlaylist.single()) + } + } + + @Test + fun `manual acquisition failure does not publish any fetched metadata`() = runTest { + val audio = temporaryFolder.newFile("manual-failed-write.mp3").apply { writeText("audio") } + val song = localSong(audio.path) + val selected = configureManualSelection() + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite(writeOutcome = LocalMediaMetadataWriteOutcome.FAILED) { writes -> + val applied = manager.applyManualMetadataSelection( + song, selected, SongMetadataApplyOptions(lyricSources = emptySet(), writeLocalMetadata = true), + isLatestRequest = { true }, onProgress = {} + ) + + assertEquals(false, applied) + assertEquals(1, writes.size) + assertEquals(song, manager.currentPlaylist.single()) + } + } + + @Test + fun `app-only manual acquisition survives reopening without changing the audio file`() = runTest { + val audio = temporaryFolder.newFile("manual-app-only.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + durationMs = 120_000L, + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + val selected = configureManualSelection() + manager.publishCurrentQueue(listOf(song), 0) + + withRegularFileDescriptors { + assertTrue(manager.applyManualMetadataSelection( + song, selected, + SongMetadataApplyOptions(lyricSources = setOf(EditableLyricMatchSource.KUGOU), romanizedLyrics = false), + isLatestRequest = { true }, onProgress = {} + )) + } + val reopened = LocalAudioImportManager.hydrateLocalSongTextMetadata( + context = application, + song = song, + resolveCoverFallback = false, + includeEmbeddedFallback = false, + includeEmbeddedCoverFallback = false, + resolveDurationFallback = false + ) + + assertEquals("audio", audio.readText()) + assertTrue(File(audio.path + ".npmeta.json").isFile) + assertEquals("Selected", reopened.customName) + assertEquals("Singer", reopened.customArtist) + assertEquals("[00:01.00]selected lyric", reopened.matchedLyric) + assertEquals("[00:01.00]selected translation", reopened.matchedTranslatedLyric) + assertEquals("", reopened.originalLyric) + } + + @Test + fun `superseded manual acquisition does not commit its prepared metadata`() = runTest { + val audio = temporaryFolder.newFile("manual-stale.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + val selected = configureManualSelection() + manager.publishCurrentQueue(listOf(song), 0) + var latestRequest = true + val stages = mutableListOf() + + withSuccessfulAssetWrite { writes -> + val applied = manager.applyManualMetadataSelection( + song, selected, SongMetadataApplyOptions(lyricSources = emptySet(), writeLocalMetadata = true), + isLatestRequest = { latestRequest }, + onProgress = { + stages += it + if (it == SongMetadataApplyStage.PREPARING_METADATA) latestRequest = false + } + ) + + assertEquals(false, applied) + assertTrue(writes.isEmpty()) + assertEquals(song, manager.currentPlaylist.single()) + assertEquals(false, SongMetadataApplyStage.APPLYING in stages) + } + } + + @Test + fun `manual acquisition cancellation at every preparation stage leaves the song and file unchanged`() = runTest { + val audio = temporaryFolder.newFile("manual-cancel.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy(durationMs = 120_000L) + val selected = configureManualSelection() + for (cancelAt in SongMetadataApplyStage.entries.filter { it != SongMetadataApplyStage.APPLYING }) { + manager.publishCurrentQueue(listOf(song), 0) + withSuccessfulAssetWrite { writes -> + val failure = runCatching { + manager.applyManualMetadataSelection( + song, selected, + SongMetadataApplyOptions(lyricSources = setOf(EditableLyricMatchSource.KUGOU), romanizedLyrics = false), + isLatestRequest = { true }, + onProgress = { if (it == cancelAt) throw CancellationException("cancelled at $it") } + ) + }.exceptionOrNull() + + assertTrue("$cancelAt", failure is CancellationException) + assertTrue(writes.isEmpty()) + assertEquals(song, manager.currentPlaylist.single()) + assertEquals("audio", audio.readText()) + assertEquals(false, File(audio.path + ".npmeta.json").exists()) + } + } + } + + @Test + fun `manual acquisition finishes publication when cancelled after applying begins`() = runTest { + val audio = temporaryFolder.newFile("manual-commit-cancel.mp3").apply { writeText("audio") } + val song = localSong(audio.path) + val selected = configureManualSelection() + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { writes -> + lateinit var request: Deferred + request = async(start = CoroutineStart.LAZY) { + manager.applyManualMetadataSelection( + song, selected, SongMetadataApplyOptions(lyricSources = emptySet(), writeLocalMetadata = true), + isLatestRequest = { true }, + onProgress = { if (it == SongMetadataApplyStage.APPLYING) request.cancel() } + ) + } + request.start() + request.join() + + assertTrue(request.isCancelled) + assertEquals(1, writes.size) + assertEquals("Selected", manager.currentPlaylist.single().customName) + } + } + @Test fun `first cover edit survives reopening and another edit before restoring no artwork`() = runTest { val audio = temporaryFolder.newFile("first-cover.mp3").apply { writeText("audio") } @@ -617,6 +1350,26 @@ class PlayerManagerLocalMetadataEditRegressionTest { romanizedLyrics = "replacement romanization" ) + private suspend fun configureManualSelection(): SongSearchInfo { + val selected = SongSearchInfo("42", "Selected", "Singer", "02:00", MusicPlatform.CLOUD_MUSIC, null, null) + val api = mock(CloudMusicSearchApi::class.java) + `when`(repositories.cloudMusicSearchApi).thenReturn(api) + `when`(api.getSongMetadataOnly("42")).thenReturn(SongDetails("42", "Selected", "Singer", "Album", null, null)) + val matches = listOf(RankedEditableLyricMatch( + candidate = EditableLyricMatchCandidate( + id = "kugou-42", source = EditableLyricMatchSource.KUGOU, + title = "Selected", artist = "Singer", durationMs = 120_000L, + lyrics = "[00:01.00]selected lyric", translatedLyrics = "[00:01.00]selected translation" + ), + score = 100, durationDeltaMs = 0L, confidence = EditableLyricMatchConfidence.HIGH + )) + val matcher = mock(EditableLyricsMatcher::class.java, Answer { invocation -> + if (invocation.method.name == "matchLyrics") matches else Answers.RETURNS_DEFAULTS.answer(invocation) + }) + `when`(repositories.editableLyricsMatcher).thenReturn(matcher) + return selected + } + private suspend fun withConfirmedCoverAbsence(confirmed: Boolean, block: suspend () -> Unit) = withRegularFileDescriptors { mockStatic(LocalCoverBaselineInspector::class.java, Answer { invocation -> @@ -647,6 +1400,8 @@ class PlayerManagerLocalMetadataEditRegressionTest { private suspend fun withSuccessfulAssetWrite( afterFirstWrite: ((SongItem) -> Unit)? = null, + onWrite: ((SongItem, String?, Boolean, Boolean) -> Unit)? = null, + writeOutcome: LocalMediaMetadataWriteOutcome = LocalMediaMetadataWriteOutcome.SUCCESS, block: suspend (List) -> Unit ) = withContext(Dispatchers.IO) { val writes = mutableListOf() @@ -655,14 +1410,45 @@ class PlayerManagerLocalMetadataEditRegressionTest { if (invocation.method.name == "writeLocalEditableMetadata") { val target = invocation.getArgument(1) writes += target + onWrite?.invoke(target, invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4)) if (writes.size == 1) afterFirstWrite?.invoke(target) - LocalMediaMetadataWriteOutcome.SUCCESS + writeOutcome } else { invocation.callRealMethod() } }).use { block(writes) } } + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun withCapturedPlatformWrite( + inspection: LocalLyricsScanMetadata?, + block: suspend (List, () -> Int) -> Unit + ) { + Dispatchers.setMain(Dispatchers.Unconfined) + try { + withContext(Dispatchers.IO) { + val writes = mutableListOf() + var inspectionCount = 0 + val facade = Class.forName("moe.ouom.neriplayer.data.local.media.LocalMediaSupportFacadeInspectKt") + mockStatic(facade, Answer { invocation -> + when (invocation.method.name) { + "inspectEmbeddedLyricsImpl" -> { + inspectionCount++ + inspection + } + "writeEditableMetadataImpl" -> { + writes += invocation.getArgument(2) + LocalMediaMetadataWriteOutcome.SUCCESS + } + else -> invocation.callRealMethod() + } + }).use { block(writes) { inspectionCount } } + } + } finally { + Dispatchers.resetMain() + } + } + private fun cover(name: String, color: Int = Color.BLACK): File = temporaryFolder.newFile(name).also { file -> val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) try { diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceColorOsLyricsTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceColorOsLyricsTest.kt index 8fb5270c34..3657ab81d0 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceColorOsLyricsTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceColorOsLyricsTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import moe.ouom.neriplayer.core.player.PlayerManager @@ -58,6 +59,7 @@ class AudioPlayerServiceColorOsLyricsTest { val bluetoothPayload = PlayerManager.externalBluetoothLyricPayloadFlow.value fixture.publish("first") + advanceTimeBy(500L) runCurrent() assertEquals("[00:01.000]first\n", JSONObject(requireNotNull(fixture.metadata.last().getString("lyricInfo"))).getString("lyric")) @@ -65,6 +67,7 @@ class AudioPlayerServiceColorOsLyricsTest { assertEquals(initialNotifications + 1, fixture.notifications) PlayerManager.colorOsLockscreenLyricsEnabled = false + advanceTimeBy(500L) runCurrent() assertNull(fixture.metadata.last().getString("lyricInfo")) assertEquals(initialNotifications + 2, fixture.notifications) @@ -91,6 +94,8 @@ class AudioPlayerServiceColorOsLyricsTest { assertEquals(initialNotifications, fixture.notifications) fixture.screenInteractive(true) fixture.screenInteractive(true) + advanceTimeBy(500L) + runCurrent() assertEquals("[00:01.000]second\n", JSONObject(requireNotNull(fixture.metadata.last().getString("lyricInfo"))).getString("lyric")) assertEquals(initialNotifications + 1, fixture.notifications) diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwnerTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwnerTest.kt index d509c8f851..e6ed780304 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwnerTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/artwork/PlaybackArtworkOwnerTest.kt @@ -20,6 +20,113 @@ import org.mockito.Mockito.`when` @OptIn(ExperimentalCoroutinesApi::class) class PlaybackArtworkOwnerTest { + @Test + fun `restoring known empty local artwork clears media and notification bitmaps`() = runTest { + val editedSource = "content://covers/edited-local.jpg" + val sources = FakeCoverSources().apply { + local = true + immediateSource = editedSource + resolvedLocal = editedSource + } + val owner = owner(sources, PlaybackArtworkBitmapLoader { bitmap() }) + val edited = song(1).copy(customCoverUrl = editedSource) + owner.observe(edited) + runCurrent() + assertTrue(owner.snapshotFor(edited).mediaReady) + assertTrue(owner.snapshotFor(edited).notificationReady) + + sources.immediateSource = null + val temporarilyUnresolved = edited.copy(customCoverUrl = null) + assertEquals(editedSource, owner.observe(temporarilyUnresolved).coverSource) + + val restored = temporarilyUnresolved.copy(originalCoverUrl = "") + val cleared = owner.observe(restored) + assertNull(cleared.coverSource) + assertNull(cleared.mediaBitmap) + assertNull(cleared.notificationBitmap) + assertFalse(cleared.mediaReady) + assertFalse(cleared.notificationReady) + assertFalse(cleared.pending) + runCurrent() + assertNull(owner.observe(restored).coverSource) + + sources.immediateSource = "content://covers/new-local.jpg" + val editedAgain = restored.copy(customCoverUrl = sources.immediateSource) + owner.observe(editedAgain) + runCurrent() + assertEquals(sources.immediateSource, owner.snapshotFor(editedAgain).coverSource) + assertTrue(owner.snapshotFor(editedAgain).mediaReady) + owner.close() + } + + @Test + fun `late local bitmap cannot repopulate artwork after an empty restore`() = runTest { + val pending = CompletableDeferred() + val sources = FakeCoverSources().apply { + local = true + immediateSource = "content://covers/late-local.jpg" + } + val owner = owner(sources, PlaybackArtworkBitmapLoader { pending.await() }) + val edited = song(1).copy(customCoverUrl = sources.immediateSource) + owner.observe(edited) + runCurrent() + + val restored = edited.copy(customCoverUrl = null, originalCoverUrl = "") + owner.observe(restored) + pending.complete(bitmap()) + runCurrent() + + assertNull(owner.snapshotFor(restored).coverSource) + assertNull(owner.snapshotFor(restored).mediaBitmap) + assertNull(owner.snapshotFor(restored).notificationBitmap) + owner.close() + } + + @Test + fun `restoring blank local cover fields clears retained notification artwork`() = runTest { + listOf("" to null, null to "", "" to "", " " to "\t").forEach { (cover, customCover) -> + val sources = FakeCoverSources().apply { + local = true + immediateSource = "content://covers/edited.jpg" + } + val owner = owner(sources, PlaybackArtworkBitmapLoader { bitmap() }) + val edited = song(1).copy(customCoverUrl = sources.immediateSource) + owner.observe(edited) + runCurrent() + assertTrue(owner.snapshotFor(edited).notificationReady) + + val restored = edited.copy(coverUrl = cover, customCoverUrl = customCover, originalCoverUrl = "") + val cleared = owner.observe(restored) + runCurrent() + + assertNull(cleared.coverSource) + assertNull(owner.snapshotFor(restored).mediaBitmap) + assertNull(owner.snapshotFor(restored).notificationBitmap) + assertFalse(owner.snapshotFor(restored).pending) + owner.close() + } + } + + @Test + fun `empty original does not hide a current cover or remote artwork`() = runTest { + val source = "content://covers/current.jpg" + listOf(true, false).forEach { localSong -> + val sources = FakeCoverSources().apply { + local = localSong + immediateSource = source + } + val owner = owner(sources, PlaybackArtworkBitmapLoader { bitmap() }) + val current = song(1).copy(originalCoverUrl = "", coverUrl = source.takeIf { localSong }) + + owner.observe(current) + runCurrent() + + assertEquals(source, owner.snapshotFor(current).coverSource) + assertTrue(owner.snapshotFor(current).mediaReady) + owner.close() + } + } + @Test fun `same cover URL on another song starts a new load and hides old artwork`() = runTest { val sources = FakeCoverSources().apply { immediateSource = "https://covers/shared.jpg" } diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackNotificationPublicationTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackNotificationPublicationTest.kt new file mode 100644 index 0000000000..419a5b7ea0 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/AndroidPlaybackNotificationPublicationTest.kt @@ -0,0 +1,144 @@ +package moe.ouom.neriplayer.core.player.service.presentation + +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.media.session.MediaSession +import moe.ouom.neriplayer.core.player.host.PlayerPresentationHost +import moe.ouom.neriplayer.core.player.service.AudioPlayerService +import moe.ouom.neriplayer.core.player.service.notification.StatusBarLyricNotificationState +import moe.ouom.neriplayer.core.player.testing.PlayerTestEnvironment +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class AndroidPlaybackNotificationPublicationTest { + private val context: Context = RuntimeEnvironment.getApplication() + private val notifications = checkNotNull(context.getSystemService(NotificationManager::class.java)) + private val ports = mutableListOf() + private lateinit var port: AndroidPlaybackServicePresentationPort + private val song = SongItem(1L, "Synthetic title", "Synthetic artist", "Synthetic album", 0L, 30_000L, null) + private val inputs = PlaybackServiceNotificationRenderInputs( + song = song, text = song.artist, audioRouteMuted = false, playbackControlPlaying = true, + favorite = false, interactiveFavorite = false, floatingLyricsEnabled = false, + lyricState = StatusBarLyricNotificationState(false, null), artwork = null, shareUrl = null + ) + + @Before + fun setUp() { + val presentation = mock(PlayerPresentationHost::class.java) + doAnswer { Intent(Intent.ACTION_MAIN).setPackage(context.packageName) } + .`when`(presentation).mainActivityIntent(context) + PlayerTestEnvironment.install(presentation = presentation) + notifications.cancelAll() + port = newPort() + } + + @After + fun tearDown() { + ports.forEach(AndroidPlaybackServicePresentationPort::releaseSessionForDestroy) + notifications.cancelAll() + PlayerTestEnvironment.reset() + } + + @Test + fun `actual publication tags and confirms the system media notification`() { + assertNull(port.notificationPublicationToken()) + + port.publishNotification(inputs) + + val token = checkNotNull(port.notificationPublicationToken()) + val posted = notifications.activeNotifications.single() + assertEquals(AudioPlayerService.NOTIFICATION_ID, posted.id) + assertNull(posted.tag) + assertEquals(token, publicationToken(posted.notification)) + assertEquals(song.name, posted.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()) + assertEquals(song.artist, posted.notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()) + assertTrue(port.isNotificationPublicationCurrent(token)) + } + + @Test + fun `foreground builds carry distinct tokens before the notify entry is used`() { + val first = port.buildNotification(inputs) + val second = port.buildNotification(inputs) + val anotherInstance = newPort().buildNotification(inputs) + + assertNotEquals(publicationToken(first), publicationToken(second)) + assertNotEquals(publicationToken(first), publicationToken(anotherInstance)) + assertNull(port.notificationPublicationToken()) + assertFalse(port.isNotificationPublicationCurrent(publicationToken(first))) + + notifications.notify(AudioPlayerService.NOTIFICATION_ID, first) + + assertTrue(port.isNotificationPublicationCurrent(publicationToken(first))) + assertFalse(port.isNotificationPublicationCurrent(publicationToken(second))) + } + + @Test + fun `a later identical publication invalidates the earlier token`() { + port.publishNotification(inputs) + val first = checkNotNull(port.notificationPublicationToken()) + + port.publishNotification(inputs) + + val latest = checkNotNull(port.notificationPublicationToken()) + assertNotEquals(first, latest) + assertFalse(port.isNotificationPublicationCurrent(first)) + assertTrue(port.isNotificationPublicationCurrent(latest)) + } + + @Test + fun `another notification id cannot confirm the media publication token`() { + val notification = port.buildNotification(inputs) + notifications.notify(AudioPlayerService.NOTIFICATION_ID + 1, notification) + + assertFalse(port.isNotificationPublicationCurrent(publicationToken(notification))) + } + + @Test + fun `a tagged notification cannot confirm the untagged media publication`() { + val notification = port.buildNotification(inputs) + notifications.notify("synthetic-other-channel", AudioPlayerService.NOTIFICATION_ID, notification) + + assertFalse(port.isNotificationPublicationCurrent(publicationToken(notification))) + } + + @Test + fun `an untagged media notification without the token cannot confirm a publication`() { + val notification = port.buildNotification(inputs) + val token = publicationToken(notification) + notification.extras.remove(PublicationTokenExtra) + notifications.notify(AudioPlayerService.NOTIFICATION_ID, notification) + + assertFalse(port.isNotificationPublicationCurrent(token)) + } + + private fun newPort(): AndroidPlaybackServicePresentationPort = AndroidPlaybackServicePresentationPort(context) + .also { instance -> + instance.initializeSession(object : MediaSession.Callback() {}) + ports += instance + } + + private fun publicationToken(notification: Notification): String = + checkNotNull(notification.extras.getString(PublicationTokenExtra)) + + private companion object { + const val PublicationTokenExtra = "moe.ouom.neriplayer.media.publication" + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationDeliveryRegressionTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationDeliveryRegressionTest.kt new file mode 100644 index 0000000000..d544be1a04 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackNotificationDeliveryRegressionTest.kt @@ -0,0 +1,265 @@ +package moe.ouom.neriplayer.core.player.service.presentation + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.core.player.metadata.ExternalBluetoothLyricPayload +import moe.ouom.neriplayer.core.player.service.artwork.PlaybackArtworkOwner +import moe.ouom.neriplayer.core.player.service.artwork.PlaybackArtworkSnapshot +import moe.ouom.neriplayer.core.player.service.notification.StatusBarLyricNotificationState +import moe.ouom.neriplayer.data.local.media.displayName +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.SleepTimerState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Answers +import org.mockito.Mockito.mock + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class PlaybackNotificationDeliveryRegressionTest { + @Test + fun `a burst coalesces to its last title without losing it to silent system rate limits`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + repeat(20) { fixture.publish("Edited $it") } + + advanceTimeBy(5_000L) + runCurrent() + + assertEquals("Edited 19", fixture.port.delivered?.song?.displayName()) + assertTrue("the media channel must merge intermediate saves", fixture.port.attempts.size <= 4) + assertTrue(fixture.port.isNotificationPublicationCurrent(checkNotNull(fixture.port.notificationPublicationToken()))) + } + + @Test + fun `the latest save retries a silent drop caused by other package notifications`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + advanceTimeBy(1_500L) + runCurrent() + fixture.port.fillPackageQuota() + fixture.publish("Restored") + assertEquals("Original", fixture.port.delivered?.song?.displayName()) + + advanceTimeBy(5_000L) + runCurrent() + + assertEquals("Restored", fixture.port.delivered?.song?.displayName()) + assertTrue(fixture.port.attempts.count { it.second == "Restored" } in 2..3) + } + + @Test + fun `a newer save cancels retries for the older silently dropped metadata`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + advanceTimeBy(1_500L) + runCurrent() + fixture.port.fillPackageQuota() + fixture.publish("Superseded") + fixture.publish("Restored") + + advanceTimeBy(6_000L) + runCurrent() + + assertEquals("Restored", fixture.port.delivered?.song?.displayName()) + assertEquals(1, fixture.port.attempts.count { it.second == "Superseded" }) + } + + @Test + fun `destroy cancels a queued save and all pending publication verification`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + fixture.publish("Restored") + fixture.owner.releaseSessionForDestroy() + + advanceTimeBy(6_000L) + runCurrent() + + assertEquals(listOf("Original"), fixture.port.attempts.map { it.second }) + } + + @Test + fun `leaving foreground cancels queued notifications before the session is destroyed`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + fixture.publish("Restored") + fixture.owner.updateNotification(false, false, StatusBarLyricNotificationState(false, null), false) + + advanceTimeBy(6_000L) + runCurrent() + + assertEquals(listOf("Original"), fixture.port.attempts.map { it.second }) + } + + @Test + fun `same value forced refreshes remain deliverable and share the fixed publication window`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + repeat(20) { fixture.publish("Original", force = true) } + + advanceTimeBy(5_000L) + runCurrent() + + assertEquals(listOf("Original", "Original"), fixture.port.attempts.map { it.second }) + } + + @Test + fun `continuous new values do not postpone a fixed publication window`() = runTest { + val fixture = Fixture(this) + fixture.publish("Original") + repeat(15) { + advanceTimeBy(100L) + runCurrent() + fixture.publish("Edited $it") + } + advanceTimeBy(500L) + runCurrent() + + assertEquals("Edited 14", fixture.port.delivered?.song?.displayName()) + assertTrue(fixture.port.attempts.size in 3..5) + assertTrue(fixture.port.attempts.zipWithNext().all { (previous, next) -> next.first - previous.first >= 500L }) + } + + @Test + fun `silent rejection exhausts two retries without leaving a repeating timer`() = runTest { + val fixture = Fixture(this) + fixture.port.rejectAll = true + fixture.publish("Restored") + + advanceTimeBy(30_000L) + runCurrent() + + assertEquals(3, fixture.port.attempts.size) + assertEquals(null, fixture.port.delivered) + } + + @Test + fun `a transient publisher exception can recover without losing the latest request`() = runTest { + val fixture = Fixture(this) + fixture.port.publishFailure = IllegalStateException("synthetic notification failure") + fixture.publish("Restored") + + advanceTimeBy(6_000L) + runCurrent() + + assertEquals("Restored", fixture.port.delivered?.song?.displayName()) + assertEquals(2, fixture.port.attempts.size) + } + + @Test + fun `a failed confirmation remains bounded and destruction cancels its retry`() = runTest { + val fixture = Fixture(this) + fixture.port.confirmationFailure = IllegalStateException("synthetic confirmation failure") + fixture.publish("Restored") + advanceTimeBy(1_001L) + runCurrent() + fixture.owner.releaseSessionForDestroy() + val attemptsAtDestroy = fixture.port.attempts.size + + advanceTimeBy(30_000L) + runCurrent() + + assertEquals(attemptsAtDestroy, fixture.port.attempts.size) + assertTrue(attemptsAtDestroy in 1..2) + } + + @Test + fun `cancellation from the publisher is propagated without scheduling retries`() = runTest { + val fixture = Fixture(this) + fixture.port.publishFailure = CancellationException("synthetic cancellation") + assertThrows(CancellationException::class.java) { fixture.publish("Restored") } + + advanceTimeBy(30_000L) + runCurrent() + + assertEquals(1, fixture.port.attempts.size) + } + + @Test + fun `cancellation while confirming does not turn into another publication`() = runTest { + val fixture = Fixture(this) + fixture.port.confirmationFailure = CancellationException("synthetic cancellation") + fixture.publish("Restored") + + advanceTimeBy(30_000L) + runCurrent() + + assertEquals(1, fixture.port.attempts.size) + } + + private class Fixture(scope: TestScope) { + private val source = Source() + val port = RateLimitedPort { scope.testScheduler.currentTime } + private val artwork = mock(PlaybackArtworkOwner::class.java) { call -> + when (call.method.name) { + "observe", "snapshotFor" -> PlaybackArtworkSnapshot(null, null, null, false, false, false) + else -> Answers.RETURNS_DEFAULTS.answer(call) + } + } + val owner = PlaybackServicePresentationOwner(source, port, artwork, scope.backgroundScope) + + fun publish(title: String, force: Boolean = false) { + source.song = source.song.copy(customName = title) + owner.updateNotification(force, true, StatusBarLyricNotificationState(false, null), false) + } + } + + private class RateLimitedPort(private val now: () -> Long) : PlaybackServicePresentationPort by mock( + PlaybackServicePresentationPort::class.java + ) { + val attempts = mutableListOf>() + private val packageEnqueues = ArrayDeque() + var delivered: PlaybackServiceNotificationRenderInputs? = null + private set + private var attemptedToken: String? = null + private var deliveredToken: String? = null + var rejectAll = false + var publishFailure: RuntimeException? = null + var confirmationFailure: RuntimeException? = null + + override fun elapsedRealtime(): Long = now() + + override fun publishNotification(inputs: PlaybackServiceNotificationRenderInputs) { + attempts += now() to inputs.song?.displayName() + publishFailure?.let { publishFailure = null; throw it } + attemptedToken = "publication-${attempts.size}" + while (packageEnqueues.firstOrNull()?.let { it <= now() - 1_000L } == true) packageEnqueues.removeFirst() + packageEnqueues.addLast(now()) + if (!rejectAll && packageEnqueues.size <= 5) { + delivered = inputs + deliveredToken = attemptedToken + } + } + + fun fillPackageQuota() = repeat(5) { packageEnqueues.addLast(now()) } + + override fun notificationPublicationToken(): String? = attemptedToken + + override fun isNotificationPublicationCurrent(token: String): Boolean { + confirmationFailure?.let { throw it } + return deliveredToken == token + } + } + + private class Source : PlaybackServicePresentationSource { + var song = SongItem(id = 1, name = "Original", artist = "Synthetic artist", album = "Synthetic album", + albumId = 0L, durationMs = 30_000L, coverUrl = null) + override fun playback() = PlaybackServicePlaybackSnapshot(song, true, 1_000L, 0L, + false, true, true, false, true, false, 1f) + override fun metadata() = PlaybackServiceMetadataInputs(ExternalBluetoothLyricPayload(), null, false) + override fun timer() = PlaybackServiceTimerInputs(SleepTimerState(), "") + override fun favoriteSongKeys() = emptySet() + override fun localPlaylistsReady() = true + override fun isLocalSong(song: SongItem) = false + override fun shareUrl(song: SongItem): String? = null + override suspend fun setFloatingLyricsEnabled(enabled: Boolean) = Unit + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServiceMatchedFavoritesTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServiceMatchedFavoritesTest.kt index 0d1ad46d05..058b3b5ba3 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServiceMatchedFavoritesTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServiceMatchedFavoritesTest.kt @@ -4,7 +4,10 @@ import android.app.Application import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.core.player.metadata.ExternalBluetoothLyricPayload import moe.ouom.neriplayer.core.player.presentation.widget.PlaybackWidgetState @@ -31,6 +34,7 @@ import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.Mockito.mockingDetails +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class PlaybackServiceMatchedFavoritesTest { private val remote = SongItem( @@ -80,6 +84,8 @@ class PlaybackServiceMatchedFavoritesTest { favorites() assertTrue(fixture.owner.refreshFavoriteSongKeys()) fixture.publish() + advanceTimeBy(500L) + runCurrent() assertFalse(fixture.notification().favorite) assertFalse(fixture.notification().interactiveFavorite) assertFalse(fixture.widget().isFavorite) @@ -130,6 +136,8 @@ class PlaybackServiceMatchedFavoritesTest { ) assertTrue(fixture.owner.refreshFavoriteSongKeys()) fixture.publish() + advanceTimeBy(500L) + runCurrent() assertFalse(fixture.notification().favorite) assertFalse(fixture.widget().isFavorite) } diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwnerTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwnerTest.kt index a4d2468648..950830c7d4 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwnerTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/presentation/PlaybackServicePresentationOwnerTest.kt @@ -11,6 +11,7 @@ import android.media.session.PlaybackState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import moe.ouom.neriplayer.core.player.metadata.ExternalBluetoothLyricPayload import moe.ouom.neriplayer.core.player.presentation.widget.PlaybackWidgetState @@ -144,7 +145,11 @@ class PlaybackServicePresentationOwnerTest { owner.updateNotification(false, true, lyricState, false) owner.updateNotification(false, true, lyricState, false) owner.updateNotification(false, true, lyricState, false) + advanceTimeBy(500L) + runCurrent() owner.updateNotification(true, true, lyricState, false) + advanceTimeBy(500L) + runCurrent() assertEquals(3, callCount(port, "publishNotification")) assertEquals(3, source.shareUrlRequests) @@ -167,6 +172,8 @@ class PlaybackServicePresentationOwnerTest { source.currentPlayback = source.currentPlayback.copy(buffering = true) owner.updateNotification(false, true, lyricState, false) + advanceTimeBy(500L) + runCurrent() val bufferingInputs = mockingDetails(port).invocations .last { it.method.name == "publishNotification" }.arguments[0] as PlaybackServiceNotificationRenderInputs assertTrue(bufferingInputs.playbackControlPlaying) @@ -264,8 +271,12 @@ class PlaybackServicePresentationOwnerTest { source.lyric = ExternalBluetoothLyricPayload("first line") owner.refreshLyricMetadata(true, true, lyricState, false) owner.refreshLyricMetadata(true, true, lyricState, false) + advanceTimeBy(500L) + runCurrent() source.lyric = ExternalBluetoothLyricPayload("second line") owner.refreshLyricMetadata(true, true, lyricState, false) + advanceTimeBy(500L) + runCurrent() assertEquals(2, callCount(port, "setMetadata")) assertEquals(3, callCount(port, "publishNotification")) @@ -288,6 +299,8 @@ class PlaybackServicePresentationOwnerTest { owner.onScreenInteractive(true, lyricState, false) owner.onScreenInteractive(true, lyricState, false) + advanceTimeBy(500L) + runCurrent() assertEquals(2, callCount(port, "publishNotification")) }