feat: sync catalog, server, and MusicFree updates
This commit is contained in:
@@ -114,7 +114,7 @@ static def getVersion() {
|
||||
// }
|
||||
|
||||
def appVersion = getVersion()
|
||||
def appVersionCode = 400012
|
||||
def appVersionCode = 400015
|
||||
|
||||
|
||||
android {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="fun.upup.musicfree">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="false"
|
||||
android:theme="@style/AppTheme"
|
||||
android:supportsRtl="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:extractNativeLibs="true"
|
||||
>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.App.SplashScreen"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="musicfree" android:host="app"/>
|
||||
<data android:scheme="musicfree" android:host="install"/>
|
||||
</intent-filter>
|
||||
<!-- 处理音频文件 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="audio/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="audio/*" />
|
||||
</intent-filter>
|
||||
<!-- 处理特定音频格式 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.mp3" />
|
||||
<data android:pathPattern=".*\\.MP3" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.flac" />
|
||||
<data android:pathPattern=".*\\.FLAC" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.m4a" />
|
||||
<data android:pathPattern=".*\\.M4A" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.wav" />
|
||||
<data android:pathPattern=".*\\.WAV" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.mp3" />
|
||||
<data android:pathPattern=".*\\.MP3" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.flac" />
|
||||
<data android:pathPattern=".*\\.FLAC" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.m4a" />
|
||||
<data android:pathPattern=".*\\.M4A" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.wav" />
|
||||
<data android:pathPattern=".*\\.WAV" />
|
||||
</intent-filter>
|
||||
<!-- 处理JavaScript文件 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/javascript" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="text/javascript" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.js" />
|
||||
<data android:pathPattern=".*\\.JS" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:pathPattern=".*\\.js" />
|
||||
<data android:pathPattern=".*\\.JS" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -11,7 +11,12 @@ import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import `fun`.upup.musicfree.MainActivity
|
||||
import `fun`.upup.musicfree.R
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
@@ -23,6 +28,8 @@ import kotlin.system.exitProcess
|
||||
class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
||||
|
||||
private val reactContext: ReactApplicationContext = context;
|
||||
private val playbackDiagnosticChannelId = "musicfree_playback_diag"
|
||||
private val playbackDiagnosticNotificationId = 42042
|
||||
|
||||
override fun getName() = "NativeUtils"
|
||||
|
||||
@@ -96,6 +103,61 @@ class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun showPlaybackDiagnosticNotification(title: String, message: String, promise: Promise) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
reactContext,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!hasPermission) {
|
||||
promise.resolve(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val manager = NotificationManagerCompat.from(reactContext)
|
||||
val channel = NotificationChannelCompat.Builder(
|
||||
playbackDiagnosticChannelId,
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH,
|
||||
)
|
||||
.setName("Playback Diagnostics")
|
||||
.setDescription("Playback error and stall diagnostics")
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
|
||||
val intent = Intent(reactContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingIntentFlags =
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) android.app.PendingIntent.FLAG_IMMUTABLE else 0
|
||||
val pendingIntent = android.app.PendingIntent.getActivity(
|
||||
reactContext,
|
||||
0,
|
||||
intent,
|
||||
pendingIntentFlags,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(reactContext, playbackDiagnosticChannelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
|
||||
manager.notify(playbackDiagnosticNotificationId, notification)
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("show_playback_diagnostic_notification_failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||
fun getWindowDimensions(): WritableMap {
|
||||
val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "MusicFree",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"license": "AGPL",
|
||||
"author": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"connect-mumu": "adb kill-server & adb connect localhost:7555",
|
||||
"build-android": "cd .\\android\\ && .\\gradlew assembleRelease",
|
||||
"generate-assets": "node ./generator/generate-assets.mjs",
|
||||
"postinstall": "patch-package",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -114,6 +115,7 @@
|
||||
"husky": "^9.1.4",
|
||||
"jest": "^29.6.3",
|
||||
"lint-staged": "^15.2.7",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "2.8.8",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"react-test-renderer": "18.3.1",
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
|
||||
index 9d6d869..8f149f8 100644
|
||||
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
|
||||
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
|
||||
@@ -4,17 +4,23 @@ import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
+import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.RatingCompat
|
||||
+import android.support.v4.media.session.MediaSessionCompat
|
||||
+import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.PRIORITY_LOW
|
||||
+import androidx.core.app.NotificationManagerCompat
|
||||
+import androidx.media.session.MediaButtonReceiver
|
||||
import com.doublesymmetry.kotlinaudio.models.*
|
||||
import com.doublesymmetry.kotlinaudio.models.NotificationButton.*
|
||||
+import com.doublesymmetry.kotlinaudio.notification.NotificationManager as KotlinAudioNotificationManager
|
||||
import com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer
|
||||
import com.doublesymmetry.trackplayer.R as TrackPlayerR
|
||||
import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toMilliseconds
|
||||
@@ -94,11 +100,52 @@ class MusicService : HeadlessJsTaskService() {
|
||||
private var compactCapabilities: List<Capability> = emptyList()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
+ if (handleNotificationAction(intent)) {
|
||||
+ return START_STICKY
|
||||
+ }
|
||||
startTask(getTaskConfig(intent))
|
||||
startAndStopEmptyNotificationToAvoidANR()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
+ private fun handleNotificationAction(intent: Intent?): Boolean {
|
||||
+ val action = intent?.action ?: return false
|
||||
+ if (!this::player.isInitialized) {
|
||||
+ Timber.w("Ignoring notification action before player initialization: %s", action)
|
||||
+ return true
|
||||
+ }
|
||||
+ when (action) {
|
||||
+ ACTION_NOTIFICATION_PLAY -> play()
|
||||
+ ACTION_NOTIFICATION_PAUSE -> pause()
|
||||
+ ACTION_NOTIFICATION_NEXT -> skipToNext()
|
||||
+ ACTION_NOTIFICATION_PREVIOUS -> {
|
||||
+ dispatchPlaybackButtonEvent(MusicEvents.BUTTON_SKIP_PREVIOUS)
|
||||
+ }
|
||||
+ else -> return false
|
||||
+ }
|
||||
+ Timber.d("Handled explicit notification action: %s", action)
|
||||
+ return true
|
||||
+ }
|
||||
+
|
||||
+ private fun dispatchPlaybackButtonEvent(event: String, data: Bundle? = null) {
|
||||
+ val reactInstanceManager = reactNativeHost.reactInstanceManager
|
||||
+ if (reactInstanceManager.currentReactContext != null) {
|
||||
+ emit(event, data)
|
||||
+ return
|
||||
+ }
|
||||
+ startTask(getTaskConfig(null))
|
||||
+ scope.launch {
|
||||
+ repeat(40) {
|
||||
+ if (reactInstanceManager.currentReactContext != null) {
|
||||
+ emit(event, data)
|
||||
+ return@launch
|
||||
+ }
|
||||
+ delay(100)
|
||||
+ }
|
||||
+ Timber.w("Timed out waiting for React context before dispatching event: %s", event)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Workaround for the "Context.startForegroundService() did not then call Service.startForeground()"
|
||||
* within 5s" ANR and crash by creating an empty notification and stopping it right after. For more
|
||||
@@ -236,6 +283,11 @@ class MusicService : HeadlessJsTaskService() {
|
||||
val notificationConfig = NotificationConfig(buttonsList, accentColor, smallIcon, pendingIntent)
|
||||
|
||||
player.notificationManager.createNotification(notificationConfig)
|
||||
+ scope.launch {
|
||||
+ delay(500)
|
||||
+ player.notificationManager.createNotification(notificationConfig)
|
||||
+ player.notificationManager.invalidate()
|
||||
+ }
|
||||
|
||||
// setup progress update events if configured
|
||||
progressUpdateJob?.cancel()
|
||||
@@ -279,6 +331,138 @@ class MusicService : HeadlessJsTaskService() {
|
||||
}
|
||||
}
|
||||
|
||||
+ private fun hasNotificationCapability(capability: Capability): Boolean {
|
||||
+ return notificationCapabilities.contains(capability) || capabilities.contains(capability)
|
||||
+ }
|
||||
+
|
||||
+ private fun getMediaSessionCompat(): MediaSessionCompat? {
|
||||
+ return try {
|
||||
+ val field = KotlinAudioNotificationManager::class.java.getDeclaredField("mediaSession")
|
||||
+ field.isAccessible = true
|
||||
+ field.get(player.notificationManager) as? MediaSessionCompat
|
||||
+ } catch (error: Exception) {
|
||||
+ Timber.w(error, "Failed to access media session from notification manager")
|
||||
+ null
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private fun buildTransportControlNotification(original: Notification): Notification {
|
||||
+ val builder = NotificationCompat.Builder(this, getNotificationChannelId(original))
|
||||
+ .setContentTitle(original.extras?.getCharSequence(Notification.EXTRA_TITLE))
|
||||
+ .setContentText(original.extras?.getCharSequence(Notification.EXTRA_TEXT))
|
||||
+ .setSubText(original.extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
|
||||
+ .setContentIntent(original.contentIntent)
|
||||
+ .setDeleteIntent(original.deleteIntent)
|
||||
+ .setCategory(original.category ?: Notification.CATEGORY_TRANSPORT)
|
||||
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
+ .setOnlyAlertOnce(true)
|
||||
+ .setOngoing(player.playWhenReady)
|
||||
+ .setShowWhen(original.`when` > 0)
|
||||
+ .setWhen(original.`when`)
|
||||
+ .setSmallIcon(ExoPlayerR.drawable.exo_notification_small_icon)
|
||||
+ val mediaSession = getMediaSessionCompat()
|
||||
+ val compactActionIndexes = mutableListOf<Int>()
|
||||
+
|
||||
+ getNotificationLargeIcon(original)?.let { builder.setLargeIcon(it) }
|
||||
+ if (original.color != 0) {
|
||||
+ builder.color = original.color
|
||||
+ }
|
||||
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
+ builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
+ }
|
||||
+
|
||||
+ if (hasNotificationCapability(Capability.SKIP_TO_PREVIOUS)) {
|
||||
+ val previousIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_PREVIOUS, 1)
|
||||
+ if (previousIntent != null) {
|
||||
+ builder.addAction(
|
||||
+ ExoPlayerR.drawable.exo_notification_previous,
|
||||
+ "Previous",
|
||||
+ previousIntent
|
||||
+ )
|
||||
+ compactActionIndexes += compactActionIndexes.size
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (hasNotificationCapability(Capability.PLAY) || hasNotificationCapability(Capability.PAUSE)) {
|
||||
+ val playPauseIntent = if (player.isPlaying) {
|
||||
+ createServiceActionPendingIntent(ACTION_NOTIFICATION_PAUSE, 2)
|
||||
+ } else {
|
||||
+ createServiceActionPendingIntent(ACTION_NOTIFICATION_PLAY, 2)
|
||||
+ }
|
||||
+ if (playPauseIntent != null) {
|
||||
+ builder.addAction(
|
||||
+ if (player.isPlaying) ExoPlayerR.drawable.exo_notification_pause
|
||||
+ else ExoPlayerR.drawable.exo_notification_play,
|
||||
+ if (player.isPlaying) "Pause" else "Play",
|
||||
+ playPauseIntent
|
||||
+ )
|
||||
+ compactActionIndexes += compactActionIndexes.size
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (hasNotificationCapability(Capability.SKIP_TO_NEXT)) {
|
||||
+ val nextIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_NEXT, 3)
|
||||
+ if (nextIntent != null) {
|
||||
+ builder.addAction(
|
||||
+ ExoPlayerR.drawable.exo_notification_next,
|
||||
+ "Next",
|
||||
+ nextIntent
|
||||
+ )
|
||||
+ compactActionIndexes += compactActionIndexes.size
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
+ mediaSession?.sessionToken?.let { mediaStyle.setMediaSession(it) }
|
||||
+ if (compactActionIndexes.isNotEmpty()) {
|
||||
+ mediaStyle.setShowActionsInCompactView(*compactActionIndexes.toIntArray())
|
||||
+ }
|
||||
+
|
||||
+ builder.setStyle(mediaStyle)
|
||||
+ return builder.build()
|
||||
+ }
|
||||
+
|
||||
+ private fun createServiceActionPendingIntent(action: String, requestCode: Int): PendingIntent? {
|
||||
+ val intent = Intent(this, MusicService::class.java).apply {
|
||||
+ this.action = action
|
||||
+ `package` = packageName
|
||||
+ }
|
||||
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
+ } else {
|
||||
+ PendingIntent.FLAG_UPDATE_CURRENT
|
||||
+ }
|
||||
+ return PendingIntent.getService(this, requestCode, intent, flags)
|
||||
+ }
|
||||
+
|
||||
+ private fun getNotificationChannelId(original: Notification): String {
|
||||
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
+ original.channelId ?: getString(TrackPlayerR.string.rntp_temporary_channel_id)
|
||||
+ } else {
|
||||
+ getString(TrackPlayerR.string.rntp_temporary_channel_id)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private fun getNotificationLargeIcon(original: Notification): Bitmap? {
|
||||
+ return when {
|
||||
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
|
||||
+ original.getLargeIcon()?.loadDrawable(this)?.let { drawable ->
|
||||
+ val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1
|
||||
+ val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1
|
||||
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
|
||||
+ val canvas = android.graphics.Canvas(bitmap)
|
||||
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
+ drawable.draw(canvas)
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ else -> {
|
||||
+ @Suppress("DEPRECATION")
|
||||
+ original.largeIcon
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
private fun isCompact(capability: Capability): Boolean {
|
||||
return compactCapabilities.contains(capability)
|
||||
}
|
||||
@@ -573,7 +757,8 @@ class MusicService : HeadlessJsTaskService() {
|
||||
is NotificationState.POSTED -> {
|
||||
Timber.d("notification posted with id=%s, ongoing=%s", it.notificationId, it.ongoing)
|
||||
notificationId = it.notificationId;
|
||||
- notification = it.notification;
|
||||
+ notification = buildTransportControlNotification(it.notification)
|
||||
+ NotificationManagerCompat.from(this@MusicService).notify(notificationId!!, notification!!)
|
||||
if (it.ongoing) {
|
||||
if (player.playWhenReady) {
|
||||
startForegroundIfNecessary()
|
||||
@@ -850,5 +1035,10 @@ class MusicService : HeadlessJsTaskService() {
|
||||
|
||||
const val DEFAULT_JUMP_INTERVAL = 15.0
|
||||
const val DEFAULT_STOP_FOREGROUND_GRACE_PERIOD = 5
|
||||
+
|
||||
+ private const val ACTION_NOTIFICATION_PLAY = "com.doublesymmetry.trackplayer.notification.PLAY"
|
||||
+ private const val ACTION_NOTIFICATION_PAUSE = "com.doublesymmetry.trackplayer.notification.PAUSE"
|
||||
+ private const val ACTION_NOTIFICATION_NEXT = "com.doublesymmetry.trackplayer.notification.NEXT"
|
||||
+ private const val ACTION_NOTIFICATION_PREVIOUS = "com.doublesymmetry.trackplayer.notification.PREVIOUS"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.6.7",
|
||||
"changeLog": [
|
||||
"修复推荐歌单、搜索结果歌单、榜单详情、专辑详情在“添加到歌单”时只保存已懒加载歌曲的问题,现会保存完整歌曲列表",
|
||||
"增强 Source error 的应用内诊断日志,追加播放状态、进度和轨道上下文",
|
||||
"修复锁屏、通知栏和悬浮控制中的上一曲在后台 React 未就绪时失效的问题",
|
||||
"应用内日志新增 RemotePrevious、PlaybackError 决策与切歌目标关键 trace",
|
||||
"继续保留锁屏卡住、fake-tail 过渡和 stall 的异常通知与日志"
|
||||
],
|
||||
"download": [
|
||||
"https://musicserver.daxo.top/app/MusicFree_latest_release_universal.apk"
|
||||
]
|
||||
}
|
||||
@@ -18,9 +18,12 @@ interface IProps {
|
||||
musicList: IMusic.IMusicItem[] | null;
|
||||
canStar?: boolean;
|
||||
musicSheet?: IMusic.IMusicSheetItem | null;
|
||||
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||
addToSheetCount?: number;
|
||||
}
|
||||
export default function (props: IProps) {
|
||||
const { musicList, canStar, musicSheet } = props;
|
||||
const { musicList, canStar, musicSheet, resolveMusicListBeforeAdd, addToSheetCount } =
|
||||
props;
|
||||
|
||||
const sheetName = musicSheet?.title;
|
||||
const sheetId = musicSheet?.id;
|
||||
@@ -86,6 +89,8 @@ export default function (props: IProps) {
|
||||
showPanel("AddToMusicSheet", {
|
||||
musicItem: musicList ?? [],
|
||||
newSheetDefaultName: sheetName,
|
||||
resolveMusicItem: resolveMusicListBeforeAdd,
|
||||
displayCount: addToSheetCount,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { skipMusicItem } from "./musicInfo";
|
||||
import TrackPlayer from "@/core/trackPlayer";
|
||||
|
||||
jest.mock("react-native-gesture-handler", () => ({
|
||||
Gesture: {
|
||||
Tap: () => ({
|
||||
onStart() {
|
||||
return this;
|
||||
},
|
||||
runOnJS() {
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
Pan: () => ({
|
||||
minPointers() {
|
||||
return this;
|
||||
},
|
||||
maxPointers() {
|
||||
return this;
|
||||
},
|
||||
onUpdate() {
|
||||
return this;
|
||||
},
|
||||
onEnd() {
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
Race: jest.fn(),
|
||||
},
|
||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("react-native-reanimated", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
View: "AnimatedView",
|
||||
},
|
||||
Easing: {
|
||||
out: jest.fn((value: unknown) => value),
|
||||
exp: jest.fn(),
|
||||
},
|
||||
runOnJS: (fn: (...args: any[]) => any) => fn,
|
||||
useAnimatedStyle: jest.fn(() => ({})),
|
||||
useSharedValue: jest.fn((value: number) => ({ value })),
|
||||
withTiming: jest.fn((value: number) => value),
|
||||
}));
|
||||
|
||||
jest.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({
|
||||
left: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useColors", () => () => ({
|
||||
musicBarText: "#fff",
|
||||
}));
|
||||
|
||||
jest.mock("@/core/router", () => ({
|
||||
ROUTE_PATH: {
|
||||
MUSIC_DETAIL: "MUSIC_DETAIL",
|
||||
},
|
||||
useNavigate: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/rpx", () => (value: number) => value);
|
||||
|
||||
jest.mock("@/core/trackPlayer", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
skipToNext: jest.fn(),
|
||||
skipToPrevious: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("musicInfo skipMusicItem", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls skipToPrevious when swiping right", () => {
|
||||
skipMusicItem(1);
|
||||
|
||||
expect(TrackPlayer.skipToPrevious).toHaveBeenCalledTimes(1);
|
||||
expect(TrackPlayer.skipToNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls skipToNext when swiping left", () => {
|
||||
skipMusicItem(-1);
|
||||
|
||||
expect(TrackPlayer.skipToNext).toHaveBeenCalledTimes(1);
|
||||
expect(TrackPlayer.skipToPrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,7 @@ interface IMusicInfoProps {
|
||||
paddingLeft?: number;
|
||||
}
|
||||
|
||||
function skipMusicItem(direction: number) {
|
||||
export function skipMusicItem(direction: number) {
|
||||
if (direction === -1) {
|
||||
TrackPlayer.skipToNext();
|
||||
} else if (direction === 1) {
|
||||
|
||||
@@ -12,9 +12,10 @@ interface IHeaderProps {
|
||||
musicSheet: IMusic.IMusicSheetItem | null;
|
||||
musicList: IMusic.IMusicItem[] | null;
|
||||
canStar?: boolean;
|
||||
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||
}
|
||||
export default function Header(props: IHeaderProps) {
|
||||
const { musicSheet, musicList, canStar } = props;
|
||||
const { musicSheet, musicList, canStar, resolveMusicListBeforeAdd } = props;
|
||||
const colors = useColors();
|
||||
|
||||
const [maxLines, setMaxLines] = useState<number | undefined>(6);
|
||||
@@ -74,6 +75,8 @@ export default function Header(props: IHeaderProps) {
|
||||
canStar={canStar}
|
||||
musicList={musicList}
|
||||
musicSheet={musicSheet}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
addToSheetCount={musicSheet?.worksNum ?? musicList?.length}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -13,15 +13,24 @@ import { RequestStateCode } from "@/constants/commonConst";
|
||||
interface IMusicListProps {
|
||||
sheetInfo: IMusic.IMusicSheetItem | null;
|
||||
musicList?: IMusic.IMusicItem[] | null;
|
||||
// 是否可收藏
|
||||
// 鏄惁鍙敹钘?
|
||||
canStar?: boolean;
|
||||
// 状态
|
||||
// 鐘舵€?
|
||||
state: RequestStateCode;
|
||||
onRetry?: () => void;
|
||||
onLoadMore?: () => void;
|
||||
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||
}
|
||||
export default function SheetMusicList(props: IMusicListProps) {
|
||||
const { sheetInfo, musicList, canStar, state, onRetry, onLoadMore } = props;
|
||||
const {
|
||||
sheetInfo,
|
||||
musicList,
|
||||
canStar,
|
||||
state,
|
||||
onRetry,
|
||||
onLoadMore,
|
||||
resolveMusicListBeforeAdd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<View style={globalStyle.fwflex1}>
|
||||
@@ -36,6 +45,7 @@ export default function SheetMusicList(props: IMusicListProps) {
|
||||
canStar={canStar}
|
||||
musicSheet={sheetInfo}
|
||||
musicList={musicList}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
/>
|
||||
}
|
||||
onLoadMore={onLoadMore}
|
||||
|
||||
@@ -11,17 +11,26 @@ interface IMusicSheetPageProps {
|
||||
navTitle: string;
|
||||
sheetInfo: ICommon.WithMusicList<IMusic.IMusicSheetItemBase> | null;
|
||||
musicList?: IMusic.IMusicItem[] | null;
|
||||
// 是否可收藏
|
||||
// 鏄惁鍙敹钘?
|
||||
canStar?: boolean;
|
||||
// 状态
|
||||
// 鐘舵€?
|
||||
state: RequestStateCode;
|
||||
onRetry?: () => void;
|
||||
onLoadMore?: () => void;
|
||||
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||
}
|
||||
|
||||
export default function MusicSheetPage(props: IMusicSheetPageProps) {
|
||||
const { navTitle, sheetInfo, musicList, canStar, onLoadMore, onRetry, state } =
|
||||
props;
|
||||
const {
|
||||
navTitle,
|
||||
sheetInfo,
|
||||
musicList,
|
||||
canStar,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
state,
|
||||
resolveMusicListBeforeAdd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<VerticalSafeAreaView style={globalStyle.fwflex1}>
|
||||
@@ -37,6 +46,7 @@ export default function MusicSheetPage(props: IMusicSheetPageProps) {
|
||||
state={state}
|
||||
onRetry={onRetry}
|
||||
onLoadMore={onLoadMore}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
/>
|
||||
<MusicBar />
|
||||
</VerticalSafeAreaView>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from "react";
|
||||
import renderer, { act } from "react-test-renderer";
|
||||
import AddToMusicSheet from "./addToMusicSheet";
|
||||
|
||||
const mockShowDialog = jest.fn();
|
||||
const mockAddMusic = jest.fn();
|
||||
const mockHidePanel = jest.fn();
|
||||
|
||||
jest.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({
|
||||
bottom: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../base/panelBase", () => {
|
||||
return ({ renderBody }: any) => renderBody();
|
||||
});
|
||||
|
||||
jest.mock("../base/panelHeader", () => {
|
||||
return () => null;
|
||||
});
|
||||
|
||||
jest.mock("@/components/base/listItem", () => {
|
||||
const React = require("react");
|
||||
const { Pressable } = require("react-native");
|
||||
|
||||
function ListItem(props: any) {
|
||||
return React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: props.testID ?? "list-item",
|
||||
onPress: props.onPress,
|
||||
},
|
||||
props.children,
|
||||
);
|
||||
}
|
||||
|
||||
ListItem.ListItemImage = () => null;
|
||||
ListItem.Content = () => null;
|
||||
|
||||
return ListItem;
|
||||
});
|
||||
|
||||
jest.mock("react-native-gesture-handler", () => ({
|
||||
FlatList: ({ data, ListHeaderComponent, renderItem }: any) => (
|
||||
<>
|
||||
{ListHeaderComponent}
|
||||
{data.map((item: any) => renderItem({ item }))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("../usePanel", () => ({
|
||||
hidePanel: (...args: any[]) => mockHidePanel(...args),
|
||||
showPanel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/core/musicSheet", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
addMusic: (...args: any[]) => mockAddMusic(...args),
|
||||
},
|
||||
useSheetsBase: () => [
|
||||
{
|
||||
id: "sheet-1",
|
||||
title: "收藏",
|
||||
worksNum: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock("@/components/dialogs/useDialog", () => ({
|
||||
showDialog: (...args: any[]) => mockShowDialog(...args),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/toast", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
success: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/core/i18n", () => ({
|
||||
useI18N: () => ({
|
||||
t: (key: string, vars?: Record<string, any>) => {
|
||||
if (key === "panel.addToMusicSheet.title") {
|
||||
return `添加到歌单 (${vars?.count ?? 0}首)`;
|
||||
}
|
||||
if (key === "panel.addToMusicSheet.toast.success") {
|
||||
return "已添加到歌单";
|
||||
}
|
||||
if (key === "panel.addToMusicSheet.toast.fail") {
|
||||
return "添加到歌单失败";
|
||||
}
|
||||
if (key === "panel.addToMusicSheet.newMusicSheet") {
|
||||
return "新建歌单";
|
||||
}
|
||||
if (key === "panel.addToMusicSheet.count") {
|
||||
return `${vars?.count ?? 0}首`;
|
||||
}
|
||||
if (key === "common.loading") {
|
||||
return "加载中";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/constants/assetsConst", () => ({
|
||||
ImgAsset: {
|
||||
add: "add",
|
||||
albumDefault: "albumDefault",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/rpx", () => {
|
||||
const fn = (value: number) => value;
|
||||
(fn as any).vmax = (value: number) => value;
|
||||
return fn;
|
||||
});
|
||||
|
||||
describe("AddToMusicSheet", () => {
|
||||
beforeEach(() => {
|
||||
mockShowDialog.mockReset();
|
||||
mockAddMusic.mockReset();
|
||||
mockHidePanel.mockReset();
|
||||
});
|
||||
|
||||
it("resolves the full list before saving to a sheet", async () => {
|
||||
const partialList = [{ id: "song-1" }] as IMusic.IMusicItem[];
|
||||
const fullList = [
|
||||
{ id: "song-1" },
|
||||
{ id: "song-2" },
|
||||
{ id: "song-3" },
|
||||
] as IMusic.IMusicItem[];
|
||||
const resolveMusicItem = jest.fn().mockResolvedValue(fullList);
|
||||
|
||||
let tree: renderer.ReactTestRenderer;
|
||||
await act(async () => {
|
||||
tree = renderer.create(
|
||||
<AddToMusicSheet
|
||||
musicItem={partialList}
|
||||
resolveMusicItem={resolveMusicItem}
|
||||
displayCount={3}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const pressables = tree!.root.findAllByType(require("react-native").Pressable);
|
||||
const targetSheetButton = pressables[1];
|
||||
|
||||
await act(async () => {
|
||||
targetSheetButton.props.onPress();
|
||||
});
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledTimes(1);
|
||||
const [, dialogPayload] = mockShowDialog.mock.calls[0];
|
||||
await dialogPayload.task();
|
||||
|
||||
expect(resolveMusicItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddMusic).toHaveBeenCalledWith("sheet-1", fullList);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import rpx, { vmax } from "@/utils/rpx";
|
||||
import ListItem from "@/components/base/listItem";
|
||||
@@ -12,20 +12,57 @@ import { hidePanel, showPanel } from "../usePanel";
|
||||
import PanelHeader from "../base/panelHeader";
|
||||
import MusicSheet, { useSheetsBase } from "@/core/musicSheet";
|
||||
import { useI18N } from "@/core/i18n";
|
||||
import { showDialog } from "@/components/dialogs/useDialog";
|
||||
|
||||
interface IAddToMusicSheetProps {
|
||||
musicItem: IMusic.IMusicItem | IMusic.IMusicItem[];
|
||||
// 如果是新建歌单,可以传入一个默认的名称
|
||||
resolveMusicItem?: () => Promise<IMusic.IMusicItem[]>;
|
||||
displayCount?: number;
|
||||
// 濡傛灉鏄柊寤烘瓕鍗曪紝鍙互浼犲叆涓€涓粯璁ょ殑鍚嶇О
|
||||
newSheetDefaultName?: string;
|
||||
}
|
||||
|
||||
export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||
const sheets = useSheetsBase();
|
||||
|
||||
const { musicItem = [], newSheetDefaultName } = props ?? {};
|
||||
const {
|
||||
musicItem = [],
|
||||
newSheetDefaultName,
|
||||
resolveMusicItem,
|
||||
displayCount,
|
||||
} = props ?? {};
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const { t } = useI18N();
|
||||
|
||||
const fallbackMusicList = useMemo(
|
||||
() => (Array.isArray(musicItem) ? musicItem : [musicItem]),
|
||||
[musicItem],
|
||||
);
|
||||
|
||||
const addMusicToSheet = async (sheetId: string) => {
|
||||
hidePanel();
|
||||
showDialog("LoadingDialog", {
|
||||
title: t("panel.addToMusicSheet.title", {
|
||||
count: displayCount ?? fallbackMusicList.length,
|
||||
}),
|
||||
loadingText: t("common.loading"),
|
||||
task: async () => {
|
||||
const resolvedMusicList =
|
||||
(await resolveMusicItem?.()) ?? fallbackMusicList;
|
||||
await MusicSheet.addMusic(sheetId, resolvedMusicList);
|
||||
return resolvedMusicList;
|
||||
},
|
||||
onResolve(_, hideLoadingDialog) {
|
||||
hideLoadingDialog();
|
||||
Toast.success(t("panel.addToMusicSheet.toast.success"));
|
||||
},
|
||||
onReject(_, hideLoadingDialog) {
|
||||
hideLoadingDialog();
|
||||
Toast.warn(t("panel.addToMusicSheet.toast.fail"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelBase
|
||||
renderBody={() => (
|
||||
@@ -34,7 +71,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||
hideButtons
|
||||
title={
|
||||
t("panel.addToMusicSheet.title", {
|
||||
count: Array.isArray(musicItem) ? musicItem.length : 1,
|
||||
count: displayCount ?? fallbackMusicList.length,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -54,13 +91,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||
defaultName: newSheetDefaultName,
|
||||
async onSheetCreated(sheetId) {
|
||||
try {
|
||||
await MusicSheet.addMusic(
|
||||
sheetId,
|
||||
musicItem,
|
||||
);
|
||||
Toast.success(
|
||||
t("panel.addToMusicSheet.toast.success"),
|
||||
);
|
||||
await addMusicToSheet(sheetId);
|
||||
} catch {
|
||||
Toast.warn(
|
||||
t("panel.addToMusicSheet.toast.fail"),
|
||||
@@ -69,7 +100,9 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||
},
|
||||
onCancel() {
|
||||
showPanel("AddToMusicSheet", {
|
||||
musicItem: musicItem,
|
||||
musicItem,
|
||||
resolveMusicItem,
|
||||
displayCount,
|
||||
newSheetDefaultName,
|
||||
});
|
||||
},
|
||||
@@ -86,16 +119,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||
withHorizontalPadding
|
||||
key={`${sheet.id}`}
|
||||
onPress={async () => {
|
||||
try {
|
||||
await MusicSheet.addMusic(
|
||||
sheet.id,
|
||||
musicItem,
|
||||
);
|
||||
hidePanel();
|
||||
Toast.success(t("panel.addToMusicSheet.toast.success"));
|
||||
} catch {
|
||||
Toast.warn(t("panel.addToMusicSheet.toast.fail"));
|
||||
}
|
||||
await addMusicToSheet(sheet.id);
|
||||
}}>
|
||||
<ListItem.ListItemImage
|
||||
uri={sheet.coverImg}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"sidebar.languageSettings": "Language Settings",
|
||||
"sidebar.downloadManager": "Download Manager",
|
||||
"checkUpdate.error.latestVersion": "Already the latest version",
|
||||
"checkUpdate.error.checkFailed": "Failed to check for updates. Verify the update source and try again.",
|
||||
"home.recommendSheet": "Recommended Playlists",
|
||||
"home.topList": "Charts",
|
||||
"home.playHistory": "Play History",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"sidebar.languageSettings": "语言设置",
|
||||
"sidebar.downloadManager": "下载管理",
|
||||
"checkUpdate.error.latestVersion": "当前已是最新版本",
|
||||
"checkUpdate.error.checkFailed": "检查更新失败,请确认更新源可访问后重试",
|
||||
"home.recommendSheet": "推荐歌单",
|
||||
"home.topList": "榜单",
|
||||
"home.playHistory": "播放历史",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"sidebar.languageSettings": "語言設定",
|
||||
"sidebar.downloadManager": "下載管理",
|
||||
"checkUpdate.error.latestVersion": "當前已是最新版本",
|
||||
"checkUpdate.error.checkFailed": "檢查更新失敗,請確認更新來源可存取後重試",
|
||||
"home.recommendSheet": "推薦歌單",
|
||||
"home.topList": "榜單",
|
||||
"home.playHistory": "播放歷史",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { default as DeviceInfo, default as deviceInfoModule } from "react-native
|
||||
import RNFS, { exists, readFile, stat, writeFile } from "react-native-fs";
|
||||
import { URL } from "react-native-url-polyfill";
|
||||
import * as webdav from "webdav";
|
||||
import { devLog, errorLog, trace } from "../../utils/log";
|
||||
import { devLog, errorLog, persistErrorLog, trace } from "../../utils/log";
|
||||
import Network from "../../utils/network";
|
||||
import LocalMusicSheet from "../localMusicSheet";
|
||||
import MediaCache from "../mediaCache";
|
||||
|
||||
@@ -8,7 +8,7 @@ import pathConst from "@/constants/pathConst";
|
||||
import { MusicRepeatMode } from "@/constants/repeatModeConst";
|
||||
import delay from "@/utils/delay";
|
||||
import getUrlExt from "@/utils/getUrlExt";
|
||||
import { errorLog, forceTrace, trace } from "@/utils/log";
|
||||
import { errorLog, forceTrace, persistErrorLog, trace } from "@/utils/log";
|
||||
import { createMediaIndexMap } from "@/utils/mediaIndexMap";
|
||||
import {
|
||||
getLocalPath,
|
||||
@@ -36,11 +36,13 @@ import {
|
||||
PlaybackErrorAction,
|
||||
decidePlaybackErrorAction,
|
||||
} from "./playbackErrorDecision";
|
||||
import { shouldHandlePlaybackQueueEnded } from "./playbackTransitionDecision";
|
||||
import {
|
||||
FAKE_TAIL_SILENT_WAV_BASE64,
|
||||
resolveFakeTailAudioPath,
|
||||
shouldRewriteFakeTailAudio,
|
||||
} from "./fakeTailAudio";
|
||||
import { buildInvalidSourceLogPayload } from "./playbackFailureLog";
|
||||
|
||||
import { TrackPlayerEvents } from "@/core.defination/trackPlayer";
|
||||
import type { IAppConfig } from "@/types/core/config";
|
||||
@@ -319,6 +321,38 @@ class TrackPlayer extends EventEmitter<{
|
||||
},
|
||||
);
|
||||
|
||||
ReactNativeTrackPlayer.addEventListener(
|
||||
Event.PlaybackQueueEnded,
|
||||
async evt => {
|
||||
const currentTrack =
|
||||
await ReactNativeTrackPlayer.getActiveTrack().catch(() => null);
|
||||
const activeTrackIndex =
|
||||
await ReactNativeTrackPlayer.getActiveTrackIndex().catch(() => null);
|
||||
const shouldHandle = shouldHandlePlaybackQueueEnded({
|
||||
activeTrackIndex,
|
||||
activeTrackMarker:
|
||||
(currentTrack as { $?: string } | undefined)?.$,
|
||||
activeTrackUrl: currentTrack?.url,
|
||||
eventTrack: evt.track,
|
||||
fakeTrackMarker: internalFakeSoundKey,
|
||||
fakeTrackUrl: TrackPlayer.fakeAudioUrl,
|
||||
});
|
||||
|
||||
forceTrace("[TrackPlayer] PlaybackQueueEnded", {
|
||||
eventTrack: evt.track,
|
||||
position: evt.position,
|
||||
activeTrackIndex,
|
||||
activeTrackUrl: currentTrack?.url,
|
||||
shouldHandle,
|
||||
});
|
||||
|
||||
if (shouldHandle) {
|
||||
forceTrace("[TrackPlayer] fake-tail-queue-ended");
|
||||
await this.handlePlayEndTransition();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.serviceInited = true;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +757,13 @@ class TrackPlayer extends EventEmitter<{
|
||||
}
|
||||
} else if (message === PlayFailReason.INVALID_SOURCE) {
|
||||
trace("Invalid source, playback failed");
|
||||
persistErrorLog(
|
||||
"[TrackPlayer] invalid-source",
|
||||
buildInvalidSourceLogPayload(
|
||||
musicItem,
|
||||
!this.configService.getConfig("basic.autoStopWhenError"),
|
||||
),
|
||||
);
|
||||
await this.handlePlayFail();
|
||||
} else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) {
|
||||
// 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓?
|
||||
@@ -754,11 +795,18 @@ class TrackPlayer extends EventEmitter<{
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMusic = this.getPlayListMusicAt(this.currentIndex + 1);
|
||||
forceTrace("[TrackPlayer] skipToNext", {
|
||||
currentIndex: this.currentIndex,
|
||||
repeatMode: this.repeatMode,
|
||||
targetMusic: targetMusic
|
||||
? {
|
||||
id: targetMusic.id,
|
||||
platform: targetMusic.platform,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
await this.play(this.getPlayListMusicAt(this.currentIndex + 1), true);
|
||||
await this.play(targetMusic, true);
|
||||
}
|
||||
|
||||
async skipToPrevious(): Promise<void> {
|
||||
@@ -767,14 +815,20 @@ class TrackPlayer extends EventEmitter<{
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMusic = this.getPlayListMusicAt(
|
||||
this.currentIndex === -1 ? 0 : this.currentIndex - 1,
|
||||
);
|
||||
forceTrace("[TrackPlayer] skipToPrevious", {
|
||||
currentIndex: this.currentIndex,
|
||||
repeatMode: this.repeatMode,
|
||||
targetMusic: targetMusic
|
||||
? {
|
||||
id: targetMusic.id,
|
||||
platform: targetMusic.platform,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
await this.play(
|
||||
this.getPlayListMusicAt(this.currentIndex === -1 ? 0 : this.currentIndex - 1),
|
||||
true,
|
||||
);
|
||||
await this.play(targetMusic, true);
|
||||
}
|
||||
|
||||
async changeQuality(newQuality: IMusic.IQualityKey): Promise<boolean> {
|
||||
@@ -1335,4 +1389,3 @@ enum PlayFailReason {
|
||||
|
||||
const trackPlayer = new TrackPlayer();
|
||||
export default trackPlayer;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { buildInvalidSourceLogPayload } from "./playbackFailureLog";
|
||||
|
||||
describe("buildInvalidSourceLogPayload", () => {
|
||||
it("captures track context for invalid source diagnostics", () => {
|
||||
const payload = buildInvalidSourceLogPayload(
|
||||
{
|
||||
artist: "Singer",
|
||||
id: "song-1",
|
||||
platform: "TestPlugin",
|
||||
title: "Song",
|
||||
} as any,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
artist: "Singer",
|
||||
id: "song-1",
|
||||
platform: "TestPlugin",
|
||||
title: "Song",
|
||||
triggerAutoSkip: true,
|
||||
type: "invalid-source",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function buildInvalidSourceLogPayload(
|
||||
musicItem?: Partial<IMusic.IMusicItem> | null,
|
||||
triggerAutoSkip = false,
|
||||
) {
|
||||
return {
|
||||
artist: musicItem?.artist,
|
||||
id: musicItem?.id,
|
||||
platform: musicItem?.platform,
|
||||
title: musicItem?.title,
|
||||
triggerAutoSkip,
|
||||
type: "invalid-source",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { shouldHandlePlaybackQueueEnded } from "./playbackTransitionDecision";
|
||||
|
||||
describe("shouldHandlePlaybackQueueEnded", () => {
|
||||
it("handles queue-ended when native event says fake tail finished", () => {
|
||||
expect(
|
||||
shouldHandlePlaybackQueueEnded({
|
||||
eventTrack: 1,
|
||||
fakeTrackMarker: "__internal_fake__",
|
||||
fakeTrackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("handles queue-ended when active track is marked as fake", () => {
|
||||
expect(
|
||||
shouldHandlePlaybackQueueEnded({
|
||||
activeTrackMarker: "__internal_fake__",
|
||||
eventTrack: 0,
|
||||
fakeTrackMarker: "__internal_fake__",
|
||||
fakeTrackUrl: "musicfree://fake-audio",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores queue-ended when active track is already a real song", () => {
|
||||
expect(
|
||||
shouldHandlePlaybackQueueEnded({
|
||||
activeTrackMarker: undefined,
|
||||
activeTrackUrl: "https://example.com/real.flac",
|
||||
eventTrack: 0,
|
||||
fakeTrackMarker: "__internal_fake__",
|
||||
fakeTrackUrl: "musicfree://fake-audio",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
interface IShouldHandlePlaybackQueueEndedParams {
|
||||
activeTrackIndex?: number | null;
|
||||
activeTrackMarker?: string;
|
||||
activeTrackUrl?: string;
|
||||
eventTrack?: number | null;
|
||||
fakeTrackMarker?: string;
|
||||
fakeTrackUrl?: string;
|
||||
}
|
||||
|
||||
const defaultFakeTrackUrl = "musicfree://fake-audio";
|
||||
|
||||
export function shouldHandlePlaybackQueueEnded(
|
||||
params: IShouldHandlePlaybackQueueEndedParams,
|
||||
) {
|
||||
const {
|
||||
activeTrackIndex,
|
||||
activeTrackMarker,
|
||||
activeTrackUrl,
|
||||
eventTrack,
|
||||
fakeTrackMarker,
|
||||
fakeTrackUrl = defaultFakeTrackUrl,
|
||||
} = params;
|
||||
|
||||
if (
|
||||
!!activeTrackMarker &&
|
||||
!!fakeTrackMarker &&
|
||||
activeTrackMarker === fakeTrackMarker
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeTrackUrl === fakeTrackUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeTrackIndex === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((activeTrackIndex === null || activeTrackIndex === undefined) && eventTrack === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { PERMISSIONS, check, request } from "react-native-permissions";
|
||||
import RNTrackPlayer, { AppKilledPlaybackBehavior, Capability } from "react-native-track-player";
|
||||
import i18n from "@/core/i18n";
|
||||
import bootstrapAtom from "./bootstrap.atom";
|
||||
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions";
|
||||
import { applyTrackPlayerOptions } from "./trackPlayerOptions";
|
||||
import { getDefaultStore } from "jotai";
|
||||
|
||||
|
||||
@@ -164,14 +164,14 @@ export async function initTrackPlayer(logger?: IPerfLogger) {
|
||||
}
|
||||
logger?.mark("加载播放器");
|
||||
|
||||
await RNTrackPlayer.updateOptions({
|
||||
await applyTrackPlayerOptions({
|
||||
showExitOnNotification:
|
||||
!!Config.getConfig("basic.showExitOnNotification"),
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
updateOptions: RNTrackPlayer.updateOptions,
|
||||
icon: ImgAsset.logoTransparent,
|
||||
progressUpdateEventInterval: 1,
|
||||
...getTrackPlayerOptionPayload(
|
||||
!!Config.getConfig("basic.showExitOnNotification"),
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
),
|
||||
});
|
||||
logger?.mark("播放器初始化完成");
|
||||
trace("播放器初始化完成");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions";
|
||||
import {
|
||||
applyTrackPlayerOptions,
|
||||
getTrackPlayerOptionPayload,
|
||||
} from "./trackPlayerOptions";
|
||||
|
||||
describe("getTrackPlayerOptionPayload", () => {
|
||||
const Capability = {
|
||||
@@ -12,6 +15,8 @@ describe("getTrackPlayerOptionPayload", () => {
|
||||
|
||||
const AppKilledPlaybackBehavior = {
|
||||
ContinuePlayback: "ContinuePlayback",
|
||||
StopPlaybackAndRemoveNotification:
|
||||
"StopPlaybackAndRemoveNotification",
|
||||
} as const;
|
||||
|
||||
it("keeps the playback service foreground across short track transition gaps", () => {
|
||||
@@ -23,7 +28,7 @@ describe("getTrackPlayerOptionPayload", () => {
|
||||
|
||||
expect(options.android.stopForegroundGracePeriod).toBe(30);
|
||||
expect(options.android.appKilledPlaybackBehavior).toBe(
|
||||
AppKilledPlaybackBehavior.ContinuePlayback,
|
||||
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
|
||||
);
|
||||
expect(options.android.alwaysPauseOnInterruption).toBe(false);
|
||||
});
|
||||
@@ -44,5 +49,34 @@ describe("getTrackPlayerOptionPayload", () => {
|
||||
expect(withoutStop.capabilities).not.toContain(Capability.Stop);
|
||||
expect(withStop.notificationCapabilities).toContain(Capability.SeekTo);
|
||||
expect(withoutStop.notificationCapabilities).toContain(Capability.SeekTo);
|
||||
expect(withStop.notificationCapabilities).toContain(Capability.Play);
|
||||
expect(withStop.notificationCapabilities).not.toContain(Capability.Pause);
|
||||
expect(withoutStop.notificationCapabilities).not.toContain(Capability.Pause);
|
||||
});
|
||||
|
||||
it("reapplies notification control capabilities through updateOptions", async () => {
|
||||
const updateOptions = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const payload = await applyTrackPlayerOptions({
|
||||
showExitOnNotification: false,
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
updateOptions,
|
||||
icon: 1234,
|
||||
progressUpdateEventInterval: 1,
|
||||
});
|
||||
|
||||
expect(updateOptions).toHaveBeenCalledTimes(1);
|
||||
expect(updateOptions).toHaveBeenCalledWith({
|
||||
icon: 1234,
|
||||
progressUpdateEventInterval: 1,
|
||||
...getTrackPlayerOptionPayload(
|
||||
false,
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
),
|
||||
});
|
||||
expect(payload.notificationCapabilities).toContain(Capability.SeekTo);
|
||||
expect(payload.capabilities).toContain(Capability.SkipToNext);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,11 @@ type CapabilityLike = {
|
||||
|
||||
type AppKilledPlaybackBehaviorLike = {
|
||||
ContinuePlayback: unknown;
|
||||
StopPlaybackAndRemoveNotification: unknown;
|
||||
};
|
||||
|
||||
type UpdateOptionsFn = (options: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
export function getTrackPlayerOptionPayload(
|
||||
showExitOnNotification: boolean,
|
||||
Capability: CapabilityLike,
|
||||
@@ -30,16 +33,58 @@ export function getTrackPlayerOptionPayload(
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
];
|
||||
const notificationCapabilities = showExitOnNotification
|
||||
? [
|
||||
Capability.Play,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
Capability.Stop,
|
||||
Capability.SeekTo,
|
||||
]
|
||||
: [
|
||||
Capability.Play,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
Capability.SeekTo,
|
||||
];
|
||||
|
||||
return {
|
||||
android: {
|
||||
alwaysPauseOnInterruption: false,
|
||||
appKilledPlaybackBehavior:
|
||||
AppKilledPlaybackBehavior.ContinuePlayback,
|
||||
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
|
||||
stopForegroundGracePeriod: 30,
|
||||
},
|
||||
capabilities,
|
||||
compactCapabilities: capabilities,
|
||||
notificationCapabilities: [...capabilities, Capability.SeekTo],
|
||||
compactCapabilities: notificationCapabilities,
|
||||
notificationCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyTrackPlayerOptions({
|
||||
showExitOnNotification,
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
updateOptions,
|
||||
icon,
|
||||
progressUpdateEventInterval,
|
||||
}: {
|
||||
showExitOnNotification: boolean;
|
||||
Capability: CapabilityLike;
|
||||
AppKilledPlaybackBehavior: AppKilledPlaybackBehaviorLike;
|
||||
updateOptions: UpdateOptionsFn;
|
||||
icon: unknown;
|
||||
progressUpdateEventInterval: number;
|
||||
}) {
|
||||
const payload = {
|
||||
icon,
|
||||
progressUpdateEventInterval,
|
||||
...getTrackPlayerOptionPayload(
|
||||
showExitOnNotification,
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
),
|
||||
};
|
||||
await updateOptions(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions";
|
||||
|
||||
describe("track player remote previous support", () => {
|
||||
const Capability = {
|
||||
Play: "Play",
|
||||
Pause: "Pause",
|
||||
SkipToNext: "SkipToNext",
|
||||
SkipToPrevious: "SkipToPrevious",
|
||||
Stop: "Stop",
|
||||
SeekTo: "SeekTo",
|
||||
} as const;
|
||||
|
||||
const AppKilledPlaybackBehavior = {
|
||||
ContinuePlayback: "ContinuePlayback",
|
||||
StopPlaybackAndRemoveNotification:
|
||||
"StopPlaybackAndRemoveNotification",
|
||||
} as const;
|
||||
|
||||
it("keeps previous control exposed for notification and compact controls", () => {
|
||||
const payload = getTrackPlayerOptionPayload(
|
||||
false,
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
);
|
||||
|
||||
expect(payload.capabilities).toContain(Capability.SkipToPrevious);
|
||||
expect(payload.notificationCapabilities).toContain(
|
||||
Capability.SkipToPrevious,
|
||||
);
|
||||
expect(payload.compactCapabilities).toContain(
|
||||
Capability.SkipToPrevious,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
jest.mock("@/components/dialogs/useDialog", () => ({
|
||||
showDialog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/persistStatus", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/checkUpdate", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/toast", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
success: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/core/i18n", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
t: jest.fn((key: string) => key),
|
||||
},
|
||||
}));
|
||||
|
||||
const { showDialog } = require("@/components/dialogs/useDialog");
|
||||
const PersistStatus = require("@/utils/persistStatus").default;
|
||||
const checkUpdate = require("@/utils/checkUpdate").default;
|
||||
const Toast = require("@/utils/toast").default;
|
||||
const {
|
||||
checkUpdateAndShowResult,
|
||||
} = require("./useCheckUpdate");
|
||||
|
||||
describe("checkUpdateAndShowResult", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
PersistStatus.get.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("shows latest-version toast only when update check succeeds without new version", async () => {
|
||||
checkUpdate.mockResolvedValue({
|
||||
needUpdate: false,
|
||||
data: {
|
||||
version: "0.6.3",
|
||||
changeLog: [],
|
||||
download: [],
|
||||
},
|
||||
});
|
||||
|
||||
checkUpdateAndShowResult(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(Toast.success).toHaveBeenCalledWith(
|
||||
"checkUpdate.error.latestVersion",
|
||||
);
|
||||
expect(Toast.warn).not.toHaveBeenCalled();
|
||||
expect(showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows warning toast when update check fails", async () => {
|
||||
checkUpdate.mockResolvedValue({
|
||||
needUpdate: false,
|
||||
error: true,
|
||||
});
|
||||
|
||||
checkUpdateAndShowResult(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(Toast.warn).toHaveBeenCalledWith(
|
||||
"checkUpdate.error.checkFailed",
|
||||
);
|
||||
expect(Toast.success).not.toHaveBeenCalled();
|
||||
expect(showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows download dialog when newer version is available", async () => {
|
||||
checkUpdate.mockResolvedValue({
|
||||
needUpdate: true,
|
||||
data: {
|
||||
version: "0.6.4",
|
||||
changeLog: ["diagnostic build"],
|
||||
download: ["http://example.com/app.apk", "http://backup/app.apk"],
|
||||
},
|
||||
});
|
||||
|
||||
checkUpdateAndShowResult(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showDialog).toHaveBeenCalledWith("DownloadDialog", {
|
||||
version: "0.6.4",
|
||||
content: ["diagnostic build"],
|
||||
fromUrl: "http://example.com/app.apk",
|
||||
backUrl: "http://backup/app.apk",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ export const checkUpdateAndShowResult = (
|
||||
if (updateInfo?.needUpdate) {
|
||||
const { data } = updateInfo;
|
||||
const skipVersion = PersistStatus.get("app.skipVersion");
|
||||
console.log(skipVersion, data);
|
||||
if (
|
||||
checkSkip &&
|
||||
skipVersion &&
|
||||
@@ -28,8 +27,13 @@ export const checkUpdateAndShowResult = (
|
||||
fromUrl: data.download[0],
|
||||
backUrl: data.download[1],
|
||||
});
|
||||
} else {
|
||||
if (showToast) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
if (updateInfo?.error) {
|
||||
Toast.warn(i18n.t("checkUpdate.error.checkFailed"));
|
||||
} else {
|
||||
Toast.success(i18n.t("checkUpdate.error.latestVersion"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ interface INativeUtils extends NativeModule {
|
||||
requestStoragePermission: () => void;
|
||||
isIgnoringBatteryOptimizations: () => Promise<boolean>;
|
||||
requestIgnoreBatteryOptimizations: () => Promise<boolean>;
|
||||
showPlaybackDiagnosticNotification: (
|
||||
title: string,
|
||||
message: string,
|
||||
) => Promise<void>;
|
||||
getWindowDimensions: () => { width: number, height: number }; // Fix bug: https://github.com/facebook/react-native/issues/47080
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { RequestStateCode } from "@/constants/commonConst";
|
||||
import PluginManager from "@/core/pluginManager";
|
||||
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function useAlbumDetail(
|
||||
originalAlbumItem: IAlbum.IAlbumItem | null,
|
||||
) {
|
||||
const currentPageRef = useRef(1);
|
||||
const musicListRef = useRef<IMusic.IMusicItem[]>(
|
||||
originalAlbumItem?.musicList ?? [],
|
||||
);
|
||||
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
|
||||
const pendingLoadRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [requestState, setRequestState] = useState<RequestStateCode>(RequestStateCode.IDLE);
|
||||
const [requestState, setRequestState] = useState<RequestStateCode>(
|
||||
RequestStateCode.IDLE,
|
||||
);
|
||||
const [albumItem, setAlbumItem] = useState<IAlbum.IAlbumItemBase | null>(
|
||||
originalAlbumItem,
|
||||
);
|
||||
@@ -15,63 +23,99 @@ export default function useAlbumDetail(
|
||||
originalAlbumItem?.musicList ?? [],
|
||||
);
|
||||
|
||||
const getAlbumDetail = useCallback(
|
||||
async function () {
|
||||
// 加载中:直接退出
|
||||
if (originalAlbumItem === null ||
|
||||
requestState === RequestStateCode.FINISHED ||
|
||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestState === RequestStateCode.PENDING_REST_PAGE) {
|
||||
return;
|
||||
}
|
||||
const getAlbumDetail = useCallback(async function () {
|
||||
if (
|
||||
originalAlbumItem === null ||
|
||||
requestStateRef.current === RequestStateCode.FINISHED ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
if (currentPageRef.current === 1) {
|
||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
||||
}
|
||||
const currentPage = currentPageRef.current;
|
||||
const nextState =
|
||||
currentPage === 1
|
||||
? RequestStateCode.PENDING_FIRST_PAGE
|
||||
: RequestStateCode.PENDING_REST_PAGE;
|
||||
requestStateRef.current = nextState;
|
||||
setRequestState(nextState);
|
||||
|
||||
const result = await PluginManager.getByMedia(
|
||||
originalAlbumItem,
|
||||
)?.methods?.getAlbumInfo?.(
|
||||
originalAlbumItem,
|
||||
currentPageRef.current,
|
||||
);
|
||||
)?.methods?.getAlbumInfo?.(originalAlbumItem, currentPage);
|
||||
if (!result) {
|
||||
throw new Error();
|
||||
}
|
||||
if (result?.albumItem) {
|
||||
if (result.albumItem) {
|
||||
setAlbumItem(prev => ({
|
||||
...(prev ?? {}),
|
||||
...(result.albumItem as IAlbum.IAlbumItemBase),
|
||||
platform: originalAlbumItem.platform,
|
||||
}));
|
||||
}
|
||||
if (result?.musicList) {
|
||||
if (result.musicList) {
|
||||
setMusicList(prev => {
|
||||
if (currentPageRef.current === 1) {
|
||||
return result?.musicList ?? prev;
|
||||
} else {
|
||||
return [...prev, ...(result.musicList ?? [])];
|
||||
}
|
||||
const nextMusicList =
|
||||
currentPage === 1
|
||||
? result.musicList ?? prev
|
||||
: [...prev, ...(result.musicList ?? [])];
|
||||
musicListRef.current = nextMusicList;
|
||||
return nextMusicList;
|
||||
});
|
||||
}
|
||||
if (result.isEnd) {
|
||||
setRequestState(RequestStateCode.FINISHED);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
||||
}
|
||||
const finished =
|
||||
result.isEnd === false
|
||||
? RequestStateCode.PARTLY_DONE
|
||||
: RequestStateCode.FINISHED;
|
||||
requestStateRef.current = finished;
|
||||
setRequestState(finished);
|
||||
currentPageRef.current += 1;
|
||||
} catch {
|
||||
requestStateRef.current = RequestStateCode.ERROR;
|
||||
setRequestState(RequestStateCode.ERROR);
|
||||
} finally {
|
||||
pendingLoadRef.current = null;
|
||||
}
|
||||
},
|
||||
[requestState],
|
||||
);
|
||||
})();
|
||||
|
||||
pendingLoadRef.current = task;
|
||||
await task;
|
||||
}, [originalAlbumItem]);
|
||||
|
||||
const resolveMusicListBeforeAdd = useCallback(async () => {
|
||||
if (pendingLoadRef.current) {
|
||||
await pendingLoadRef.current;
|
||||
}
|
||||
|
||||
if (originalAlbumItem === null) {
|
||||
return musicListRef.current;
|
||||
}
|
||||
|
||||
return resolvePagedMusicList({
|
||||
initialMusicList: musicListRef.current,
|
||||
nextPage: currentPageRef.current,
|
||||
isEnd: requestStateRef.current === RequestStateCode.FINISHED,
|
||||
loadPage: async page => {
|
||||
const result = await PluginManager.getByMedia(
|
||||
originalAlbumItem,
|
||||
)?.methods?.getAlbumInfo?.(originalAlbumItem, page);
|
||||
return result ?? null;
|
||||
},
|
||||
});
|
||||
}, [originalAlbumItem]);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbumDetail();
|
||||
}, []);
|
||||
}, [getAlbumDetail]);
|
||||
|
||||
return [requestState, albumItem, musicList, getAlbumDetail] as const;
|
||||
return [
|
||||
requestState,
|
||||
albumItem,
|
||||
musicList,
|
||||
getAlbumDetail,
|
||||
resolveMusicListBeforeAdd,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { useI18N } from "@/core/i18n";
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { albumItem: originalAlbumItem } = useParams<"album-detail">();
|
||||
const [requestStateCode, albumItem, musicList, getAlbumDetail] =
|
||||
const [
|
||||
requestStateCode,
|
||||
albumItem,
|
||||
musicList,
|
||||
getAlbumDetail,
|
||||
resolveMusicListBeforeAdd,
|
||||
] =
|
||||
useAlbumDetail(originalAlbumItem);
|
||||
const { t } = useI18N();
|
||||
|
||||
@@ -18,6 +24,7 @@ export default function AlbumDetail() {
|
||||
onRetry={getAlbumDetail}
|
||||
onLoadMore={getAlbumDetail}
|
||||
musicList={musicList}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { RequestStateCode } from "@/constants/commonConst";
|
||||
import PluginManager from "@/core/pluginManager";
|
||||
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function usePluginSheetMusicList(
|
||||
originalSheetItem: IMusic.IMusicSheetItem | null,
|
||||
) {
|
||||
const currentPageRef = useRef(1);
|
||||
const musicListRef = useRef<IMusic.IMusicItem[]>(
|
||||
originalSheetItem?.musicList ?? [],
|
||||
);
|
||||
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
|
||||
const pendingLoadRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [requestState, setRequestState] = useState<RequestStateCode>(RequestStateCode.IDLE);
|
||||
const [requestState, setRequestState] = useState<RequestStateCode>(
|
||||
RequestStateCode.IDLE,
|
||||
);
|
||||
const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>(
|
||||
originalSheetItem,
|
||||
);
|
||||
@@ -15,62 +23,100 @@ export default function usePluginSheetMusicList(
|
||||
originalSheetItem?.musicList ?? [],
|
||||
);
|
||||
|
||||
const getSheetDetail = useCallback(
|
||||
async function () {
|
||||
// 加载中:直接退出
|
||||
if (originalSheetItem === null ||
|
||||
requestState === RequestStateCode.FINISHED ||
|
||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestState === RequestStateCode.PENDING_REST_PAGE) {
|
||||
return;
|
||||
}
|
||||
const getSheetDetail = useCallback(async function () {
|
||||
if (
|
||||
originalSheetItem === null ||
|
||||
requestStateRef.current === RequestStateCode.FINISHED ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
if (currentPageRef.current === 1) {
|
||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
||||
}
|
||||
const currentPage = currentPageRef.current;
|
||||
const nextState =
|
||||
currentPage === 1
|
||||
? RequestStateCode.PENDING_FIRST_PAGE
|
||||
: RequestStateCode.PENDING_REST_PAGE;
|
||||
requestStateRef.current = nextState;
|
||||
setRequestState(nextState);
|
||||
|
||||
const result = await PluginManager.getByMedia(
|
||||
originalSheetItem as any,
|
||||
)?.methods?.getMusicSheetInfo?.(
|
||||
originalSheetItem,
|
||||
currentPageRef.current,
|
||||
);
|
||||
)?.methods?.getMusicSheetInfo?.(originalSheetItem, currentPage);
|
||||
if (!result) {
|
||||
throw new Error();
|
||||
}
|
||||
if (result?.sheetItem) {
|
||||
if (result.sheetItem) {
|
||||
setSheetItem(prev => ({
|
||||
...(prev ?? {}),
|
||||
...(result.sheetItem as IMusic.IMusicSheetItem),
|
||||
platform: originalSheetItem.platform,
|
||||
}));
|
||||
}
|
||||
if (result?.musicList) {
|
||||
if (result.musicList) {
|
||||
setMusicList(prev => {
|
||||
if (currentPageRef.current === 1) {
|
||||
return result?.musicList ?? prev;
|
||||
} else {
|
||||
return [...prev, ...(result.musicList ?? [])];
|
||||
}
|
||||
const nextMusicList =
|
||||
currentPage === 1
|
||||
? result.musicList ?? prev
|
||||
: [...prev, ...(result.musicList ?? [])];
|
||||
musicListRef.current = nextMusicList;
|
||||
return nextMusicList;
|
||||
});
|
||||
}
|
||||
if (result.isEnd) {
|
||||
setRequestState(RequestStateCode.FINISHED);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
||||
}
|
||||
const finished =
|
||||
result.isEnd === false
|
||||
? RequestStateCode.PARTLY_DONE
|
||||
: RequestStateCode.FINISHED;
|
||||
requestStateRef.current = finished;
|
||||
setRequestState(finished);
|
||||
currentPageRef.current += 1;
|
||||
} catch {
|
||||
requestStateRef.current = RequestStateCode.ERROR;
|
||||
setRequestState(RequestStateCode.ERROR);
|
||||
} finally {
|
||||
pendingLoadRef.current = null;
|
||||
}
|
||||
},
|
||||
[requestState],
|
||||
);
|
||||
})();
|
||||
|
||||
pendingLoadRef.current = task;
|
||||
await task;
|
||||
}, [originalSheetItem]);
|
||||
|
||||
const resolveMusicListBeforeAdd = useCallback(async () => {
|
||||
if (pendingLoadRef.current) {
|
||||
await pendingLoadRef.current;
|
||||
}
|
||||
|
||||
if (
|
||||
originalSheetItem === null ||
|
||||
requestStateRef.current === RequestStateCode.FINISHED
|
||||
) {
|
||||
return musicListRef.current;
|
||||
}
|
||||
|
||||
return resolvePagedMusicList({
|
||||
initialMusicList: musicListRef.current,
|
||||
nextPage: currentPageRef.current,
|
||||
isEnd: requestStateRef.current === RequestStateCode.FINISHED,
|
||||
loadPage: page =>
|
||||
PluginManager.getByMedia(
|
||||
originalSheetItem as any,
|
||||
)?.methods?.getMusicSheetInfo?.(originalSheetItem, page),
|
||||
});
|
||||
}, [originalSheetItem]);
|
||||
|
||||
useEffect(() => {
|
||||
getSheetDetail();
|
||||
}, []);
|
||||
}, [getSheetDetail]);
|
||||
|
||||
return [requestState, sheetItem, musicList, getSheetDetail] as const;
|
||||
return [
|
||||
requestState,
|
||||
sheetItem,
|
||||
musicList,
|
||||
getSheetDetail,
|
||||
resolveMusicListBeforeAdd,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import i18n from "@/core/i18n";
|
||||
export default function PluginSheetDetail() {
|
||||
const { sheetInfo } = useParams<"plugin-sheet-detail">();
|
||||
|
||||
const [requestState, sheetItem, musicList, getSheetDetail] =
|
||||
const [
|
||||
requestState,
|
||||
sheetItem,
|
||||
musicList,
|
||||
getSheetDetail,
|
||||
resolveMusicListBeforeAdd,
|
||||
] =
|
||||
usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem);
|
||||
return (
|
||||
<MusicSheetPage
|
||||
@@ -18,6 +24,7 @@ export default function PluginSheetDetail() {
|
||||
state={requestState}
|
||||
onRetry={getSheetDetail}
|
||||
onLoadMore={getSheetDetail}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RequestStateCode } from "@/constants/commonConst";
|
||||
import PluginManager from "@/core/pluginManager";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function useTopListDetail(
|
||||
topListItem: IMusic.IMusicSheetItemBase | null,
|
||||
@@ -12,64 +13,107 @@ export default function useTopListDetail(
|
||||
);
|
||||
|
||||
const pageRef = useRef(1);
|
||||
const musicListRef = useRef<IMusic.IMusicItem[]>(topListItem?.musicList ?? []);
|
||||
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
|
||||
const pendingLoadRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [requestState, setRequestState] = useState(RequestStateCode.IDLE);
|
||||
|
||||
async function loadMore() {
|
||||
if (!topListItem) {
|
||||
const loadMore = useCallback(async () => {
|
||||
if (
|
||||
!topListItem ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE ||
|
||||
requestStateRef.current === RequestStateCode.FINISHED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||
requestState === RequestStateCode.PENDING_REST_PAGE ||
|
||||
requestState === RequestStateCode.FINISHED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (pageRef.current === 1) {
|
||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
||||
}
|
||||
const result = await PluginManager.getByHash(
|
||||
pluginHash,
|
||||
)?.methods?.getTopListDetail(topListItem, pageRef.current);
|
||||
if (!result) {
|
||||
throw new Error();
|
||||
}
|
||||
const currentPage = pageRef.current;
|
||||
setMergedTopListItem(
|
||||
prev =>
|
||||
({
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const currentPage = pageRef.current;
|
||||
const nextState =
|
||||
currentPage === 1
|
||||
? RequestStateCode.PENDING_FIRST_PAGE
|
||||
: RequestStateCode.PENDING_REST_PAGE;
|
||||
requestStateRef.current = nextState;
|
||||
setRequestState(nextState);
|
||||
|
||||
const result = await PluginManager.getByHash(
|
||||
pluginHash,
|
||||
)?.methods?.getTopListDetail(topListItem, currentPage);
|
||||
if (!result) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
setMergedTopListItem(prev => {
|
||||
const nextMusicList =
|
||||
currentPage === 1
|
||||
? result.musicList ?? []
|
||||
: [
|
||||
...(prev?.musicList ?? []),
|
||||
...(result.musicList ?? []),
|
||||
];
|
||||
musicListRef.current = nextMusicList;
|
||||
return {
|
||||
...prev,
|
||||
...result.topListItem,
|
||||
musicList:
|
||||
currentPage === 1
|
||||
? result.musicList ?? []
|
||||
: [
|
||||
...(prev?.musicList ?? []),
|
||||
...(result.musicList ?? []),
|
||||
],
|
||||
} as IMusic.IMusicSheetItem),
|
||||
);
|
||||
musicList: nextMusicList,
|
||||
} as IMusic.IMusicSheetItem;
|
||||
});
|
||||
|
||||
if (result.isEnd === false) {
|
||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
||||
} else {
|
||||
setRequestState(RequestStateCode.FINISHED);
|
||||
const finished =
|
||||
result.isEnd === false
|
||||
? RequestStateCode.PARTLY_DONE
|
||||
: RequestStateCode.FINISHED;
|
||||
requestStateRef.current = finished;
|
||||
setRequestState(finished);
|
||||
pageRef.current += 1;
|
||||
} catch {
|
||||
requestStateRef.current = RequestStateCode.ERROR;
|
||||
setRequestState(RequestStateCode.ERROR);
|
||||
} finally {
|
||||
pendingLoadRef.current = null;
|
||||
}
|
||||
pageRef.current++;
|
||||
} catch {
|
||||
setRequestState(RequestStateCode.ERROR);
|
||||
})();
|
||||
|
||||
pendingLoadRef.current = task;
|
||||
await task;
|
||||
}, [pluginHash, topListItem]);
|
||||
|
||||
const resolveMusicListBeforeAdd = useCallback(async () => {
|
||||
if (pendingLoadRef.current) {
|
||||
await pendingLoadRef.current;
|
||||
}
|
||||
}
|
||||
|
||||
if (topListItem === null) {
|
||||
return musicListRef.current;
|
||||
}
|
||||
|
||||
return resolvePagedMusicList({
|
||||
initialMusicList: musicListRef.current,
|
||||
nextPage: pageRef.current,
|
||||
isEnd: requestStateRef.current === RequestStateCode.FINISHED,
|
||||
loadPage: async page => {
|
||||
const result = await PluginManager.getByHash(
|
||||
pluginHash,
|
||||
)?.methods?.getTopListDetail(topListItem, page);
|
||||
return result ?? null;
|
||||
},
|
||||
});
|
||||
}, [pluginHash, topListItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (topListItem === null) {
|
||||
return;
|
||||
}
|
||||
loadMore();
|
||||
}, []);
|
||||
return [mergedTopListItem, requestState, loadMore] as const;
|
||||
}, [loadMore, topListItem]);
|
||||
|
||||
return [
|
||||
mergedTopListItem,
|
||||
requestState,
|
||||
loadMore,
|
||||
resolveMusicListBeforeAdd,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import useTopListDetail from "./hooks/useTopListDetail";
|
||||
|
||||
export default function TopListDetail() {
|
||||
const { pluginHash, topList } = useParams<"top-list-detail">();
|
||||
const [topListDetail, state, loadMore] = useTopListDetail(
|
||||
const [topListDetail, state, loadMore, resolveMusicListBeforeAdd] = useTopListDetail(
|
||||
topList,
|
||||
pluginHash,
|
||||
);
|
||||
@@ -19,6 +19,7 @@ export default function TopListDetail() {
|
||||
state={state}
|
||||
onLoadMore={loadMore}
|
||||
onRetry={loadMore}
|
||||
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import Config from "@/core/appConfig";
|
||||
import { ImgAsset } from "@/constants/assetsConst";
|
||||
import pathConst from "@/constants/pathConst";
|
||||
import musicHistory from "@/core/musicHistory";
|
||||
import PluginManager from "@/core/pluginManager";
|
||||
import RNTrackPlayer, { Event, State } from "react-native-track-player";
|
||||
import RNTrackPlayer, {
|
||||
AppKilledPlaybackBehavior,
|
||||
Capability,
|
||||
Event,
|
||||
State,
|
||||
} from "react-native-track-player";
|
||||
import TrackPlayer from "@/core/trackPlayer";
|
||||
import NativeUtils from "@/native/utils";
|
||||
import {
|
||||
createPlaybackAnomalyMonitor,
|
||||
PlaybackAnomalyReport,
|
||||
} from "@/service/playbackAnomalyMonitor";
|
||||
import { buildPlaybackErrorDiagnostics } from "@/service/playbackErrorDiagnostics";
|
||||
import { checkAndCreateDir } from "@/utils/fileUtils";
|
||||
import { errorLog, forceTrace, trace } from "@/utils/log";
|
||||
import { errorLog, forceTrace, persistErrorLog, trace } from "@/utils/log";
|
||||
import { musicIsPaused } from "@/utils/trackUtils";
|
||||
import PersistStatus from "@/utils/persistStatus";
|
||||
import { applyTrackPlayerOptions } from "@/entry/bootstrap/trackPlayerOptions";
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
@@ -17,6 +30,131 @@ import PersistStatus from "@/utils/persistStatus";
|
||||
|
||||
let resumeState: State | null;
|
||||
let serviceReadyPromise: Promise<void> | null = null;
|
||||
let playbackHealthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function toPlaybackStateValue(state?: State | string | null) {
|
||||
return (state ?? State.None) as
|
||||
| "none"
|
||||
| "ready"
|
||||
| "playing"
|
||||
| "paused"
|
||||
| "stopped"
|
||||
| "loading"
|
||||
| "buffering"
|
||||
| "error"
|
||||
| "ended";
|
||||
}
|
||||
|
||||
function formatTrackLabel(trackTitle?: string | null, artist?: string | null) {
|
||||
const title = trackTitle?.trim?.() || "\u672A\u77E5\u6B4C\u66F2";
|
||||
const singer = artist?.trim?.();
|
||||
return singer ? `${title} - ${singer}` : title;
|
||||
}
|
||||
|
||||
function toMusicRef(
|
||||
musicItem?: Partial<IMusic.IMusicItem> | null,
|
||||
) {
|
||||
if (!musicItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: musicItem.artist ?? null,
|
||||
id: musicItem.id ?? null,
|
||||
platform: musicItem.platform ?? null,
|
||||
title: musicItem.title ?? null,
|
||||
url: musicItem.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function showPlaybackDiagnosticNotification(report: PlaybackAnomalyReport) {
|
||||
const trackLabel = formatTrackLabel(report.trackTitle, report.artist);
|
||||
if (report.type === "error") {
|
||||
const title = "MusicFree \u64AD\u653E\u51FA\u9519";
|
||||
const message = `${trackLabel} \u64AD\u653E\u5931\u8D25\uff0c\u8BF7\u56DE\u5230\u5E94\u7528\u5185\u91CD\u8BD5\u3002`;
|
||||
await NativeUtils.showPlaybackDiagnosticNotification(title, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = "MusicFree \u64AD\u653E\u7591\u4F3C\u5361\u4F4F";
|
||||
const message = `${trackLabel} \u540E\u53F0\u64AD\u653E\u957F\u65F6\u95F4\u65E0\u8FDB\u5EA6\uff0c\u8BF7\u4EAE\u5C4F\u6216\u56DE\u5230\u5E94\u7528\u5185\u91CD\u8BD5\u3002`;
|
||||
await NativeUtils.showPlaybackDiagnosticNotification(title, message);
|
||||
}
|
||||
|
||||
function logPlaybackAnomaly(report: PlaybackAnomalyReport) {
|
||||
if (report.type === "error") {
|
||||
const payload = {
|
||||
activeTrackIndex: report.activeTrackIndex,
|
||||
artist: report.artist,
|
||||
code: report.code,
|
||||
diagnostics: report.diagnostics,
|
||||
message: report.message,
|
||||
platform: report.platform,
|
||||
trackTitle: report.trackTitle,
|
||||
trackUrl: report.trackUrl,
|
||||
type: report.type,
|
||||
};
|
||||
persistErrorLog("[PlaybackService] playback-error", payload);
|
||||
forceTrace("[PlaybackService] playback-error", payload, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
activeTrackIndex: report.activeTrackIndex,
|
||||
artist: report.artist,
|
||||
platform: report.platform,
|
||||
position: report.position,
|
||||
stalledForMs: report.stalledForMs,
|
||||
state: report.state,
|
||||
trackTitle: report.trackTitle,
|
||||
trackUrl: report.trackUrl,
|
||||
type: report.type,
|
||||
};
|
||||
persistErrorLog("[PlaybackService] playback-stall", payload);
|
||||
forceTrace("[PlaybackService] playback-stall", payload, "error");
|
||||
}
|
||||
|
||||
const playbackAnomalyMonitor = createPlaybackAnomalyMonitor({
|
||||
cooldownMs: 90_000,
|
||||
report: report => {
|
||||
logPlaybackAnomaly(report);
|
||||
showPlaybackDiagnosticNotification(report).catch(error => {
|
||||
forceTrace(
|
||||
"[PlaybackService] playback-notification:failed",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
},
|
||||
stallThresholdMs: 45_000,
|
||||
});
|
||||
|
||||
function startPlaybackHealthCheck() {
|
||||
if (playbackHealthCheckTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
playbackHealthCheckTimer = setInterval(() => {
|
||||
runSafely("PlaybackHealthCheck", async () => {
|
||||
const [playbackState, progress, track, activeTrackIndex] = await Promise.all([
|
||||
RNTrackPlayer.getPlaybackState(),
|
||||
RNTrackPlayer.getProgress(),
|
||||
RNTrackPlayer.getActiveTrack().catch(() => null),
|
||||
RNTrackPlayer.getActiveTrackIndex().catch(() => null),
|
||||
]);
|
||||
|
||||
playbackAnomalyMonitor.recordSnapshot({
|
||||
activeTrackIndex,
|
||||
artist: (track as { artist?: string | null } | null)?.artist,
|
||||
platform: (track as { platform?: string | null } | null)?.platform,
|
||||
position: progress.position ?? 0,
|
||||
state: toPlaybackStateValue(playbackState.state),
|
||||
trackTitle: (track as { title?: string | null } | null)?.title,
|
||||
trackUrl: (track as { url?: string | null } | null)?.url,
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
async function ensureServiceReady(from: string) {
|
||||
if (serviceReadyPromise) {
|
||||
@@ -40,10 +178,28 @@ async function ensureServiceReady(from: string) {
|
||||
await Config.setup();
|
||||
await musicHistory.setup();
|
||||
await PluginManager.setup();
|
||||
const appliedOptions = await applyTrackPlayerOptions({
|
||||
showExitOnNotification:
|
||||
!!Config.getConfig("basic.showExitOnNotification"),
|
||||
Capability,
|
||||
AppKilledPlaybackBehavior,
|
||||
updateOptions: RNTrackPlayer.updateOptions,
|
||||
icon: ImgAsset.logoTransparent,
|
||||
progressUpdateEventInterval: 1,
|
||||
});
|
||||
forceTrace("[PlaybackService] options:applied", {
|
||||
showExitOnNotification:
|
||||
!!Config.getConfig("basic.showExitOnNotification"),
|
||||
capabilities: appliedOptions.capabilities,
|
||||
compactCapabilities: appliedOptions.compactCapabilities,
|
||||
notificationCapabilities:
|
||||
appliedOptions.notificationCapabilities,
|
||||
});
|
||||
await TrackPlayer.setupTrackPlayer();
|
||||
|
||||
trace(`[TrackPlayer service] ready from ${from}`);
|
||||
forceTrace("[PlaybackService] ready", { from });
|
||||
startPlaybackHealthCheck();
|
||||
} catch (e: any) {
|
||||
serviceReadyPromise = null;
|
||||
throw e;
|
||||
@@ -97,7 +253,13 @@ module.exports = async function () {
|
||||
runSafely("RemotePause", () => TrackPlayer.pause()),
|
||||
);
|
||||
RNTrackPlayer.addEventListener(Event.RemotePrevious, () =>
|
||||
runSafely("RemotePrevious", () => TrackPlayer.skipToPrevious()),
|
||||
runSafely("RemotePrevious", async () => {
|
||||
forceTrace("[PlaybackService] RemotePrevious", {
|
||||
currentMusic: toMusicRef(TrackPlayer.currentMusic),
|
||||
previousMusic: toMusicRef(TrackPlayer.previousMusic),
|
||||
});
|
||||
await TrackPlayer.skipToPrevious();
|
||||
}),
|
||||
);
|
||||
RNTrackPlayer.addEventListener(Event.RemoteNext, () =>
|
||||
runSafely("RemoteNext", () => TrackPlayer.skipToNext()),
|
||||
@@ -170,6 +332,43 @@ module.exports = async function () {
|
||||
|
||||
RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => {
|
||||
PersistStatus.set("music.progress", evt.position);
|
||||
playbackAnomalyMonitor.recordProgress(evt.position);
|
||||
});
|
||||
|
||||
RNTrackPlayer.addEventListener(Event.PlaybackError, async evt => {
|
||||
runSafely("PlaybackError", async () => {
|
||||
const [track, activeTrackIndex, queueTrack, playbackState, progress] =
|
||||
await Promise.all([
|
||||
RNTrackPlayer.getActiveTrack().catch(() => null),
|
||||
RNTrackPlayer.getActiveTrackIndex().catch(() => null),
|
||||
RNTrackPlayer.getTrack(0).catch(() => null),
|
||||
RNTrackPlayer.getPlaybackState().catch(() => null),
|
||||
RNTrackPlayer.getProgress().catch(() => null),
|
||||
]);
|
||||
const diagnostics = buildPlaybackErrorDiagnostics({
|
||||
activeTrack: track as Record<string, unknown> | null,
|
||||
activeTrackIndex,
|
||||
currentMusic: TrackPlayer.currentMusic as
|
||||
| Record<string, unknown>
|
||||
| null,
|
||||
eventCode: evt.code,
|
||||
eventMessage: evt.message,
|
||||
persistedTrack: PersistStatus.get("music.musicItem") as
|
||||
| Record<string, unknown>
|
||||
| null,
|
||||
playbackState: toPlaybackStateValue(
|
||||
playbackState?.state as State | string | null,
|
||||
),
|
||||
progress,
|
||||
queueTrack: queueTrack as Record<string, unknown> | null,
|
||||
});
|
||||
playbackAnomalyMonitor.recordPlaybackError({
|
||||
...diagnostics,
|
||||
code: evt.code,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
message: evt.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
RNTrackPlayer.addEventListener(Event.RemoteStop, async () => {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createPlaybackAnomalyMonitor } from "./playbackAnomalyMonitor";
|
||||
|
||||
describe("createPlaybackAnomalyMonitor", () => {
|
||||
let now = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
});
|
||||
|
||||
it("reports playback errors only once within cooldown", () => {
|
||||
const report = jest.fn();
|
||||
const monitor = createPlaybackAnomalyMonitor({
|
||||
now: () => now,
|
||||
cooldownMs: 90_000,
|
||||
stallThresholdMs: 45_000,
|
||||
report,
|
||||
});
|
||||
|
||||
monitor.recordPlaybackError({
|
||||
activeTrackIndex: 1,
|
||||
artist: "Singer",
|
||||
code: "android-system",
|
||||
diagnostics: {
|
||||
playbackState: "playing",
|
||||
},
|
||||
message: "MusicFree error",
|
||||
platform: "TestPlugin",
|
||||
trackTitle: "Song",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
});
|
||||
monitor.recordPlaybackError({
|
||||
activeTrackIndex: 1,
|
||||
artist: "Singer",
|
||||
code: "android-system",
|
||||
diagnostics: {
|
||||
playbackState: "playing",
|
||||
},
|
||||
message: "MusicFree error",
|
||||
platform: "TestPlugin",
|
||||
trackTitle: "Song",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
});
|
||||
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
expect(report).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activeTrackIndex: 1,
|
||||
artist: "Singer",
|
||||
code: "android-system",
|
||||
diagnostics: {
|
||||
playbackState: "playing",
|
||||
},
|
||||
platform: "TestPlugin",
|
||||
trackTitle: "Song",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
type: "error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports stalled playback when progress does not move past threshold", () => {
|
||||
const report = jest.fn();
|
||||
const monitor = createPlaybackAnomalyMonitor({
|
||||
now: () => now,
|
||||
cooldownMs: 90_000,
|
||||
stallThresholdMs: 45_000,
|
||||
report,
|
||||
});
|
||||
|
||||
monitor.recordSnapshot({
|
||||
artist: "Singer",
|
||||
position: 12,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
});
|
||||
|
||||
now += 46_000;
|
||||
|
||||
monitor.recordSnapshot({
|
||||
artist: "Singer",
|
||||
position: 12,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
});
|
||||
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
expect(report).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artist: "Singer",
|
||||
position: 12,
|
||||
stalledForMs: 46_000,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
type: "stall",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report stall repeatedly within cooldown", () => {
|
||||
const report = jest.fn();
|
||||
const monitor = createPlaybackAnomalyMonitor({
|
||||
now: () => now,
|
||||
cooldownMs: 90_000,
|
||||
stallThresholdMs: 45_000,
|
||||
report,
|
||||
});
|
||||
|
||||
monitor.recordSnapshot({
|
||||
artist: "Singer",
|
||||
position: 24,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
});
|
||||
|
||||
now += 46_000;
|
||||
monitor.recordSnapshot({
|
||||
artist: "Singer",
|
||||
position: 24,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
});
|
||||
|
||||
now += 10_000;
|
||||
monitor.recordSnapshot({
|
||||
artist: "Singer",
|
||||
position: 24,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
});
|
||||
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes active track details in stall reports", () => {
|
||||
const report = jest.fn();
|
||||
const monitor = createPlaybackAnomalyMonitor({
|
||||
now: () => now,
|
||||
cooldownMs: 90_000,
|
||||
stallThresholdMs: 45_000,
|
||||
report,
|
||||
});
|
||||
|
||||
monitor.recordSnapshot({
|
||||
activeTrackIndex: 1,
|
||||
artist: "Singer",
|
||||
platform: "Music_Server",
|
||||
position: 180,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
} as any);
|
||||
|
||||
now += 46_000;
|
||||
|
||||
monitor.recordSnapshot({
|
||||
activeTrackIndex: 1,
|
||||
artist: "Singer",
|
||||
platform: "Music_Server",
|
||||
position: 180,
|
||||
state: "playing",
|
||||
trackTitle: "Song",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
} as any);
|
||||
|
||||
expect(report).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activeTrackIndex: 1,
|
||||
platform: "Music_Server",
|
||||
trackUrl:
|
||||
"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav",
|
||||
type: "stall",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
export type PlaybackAnomalyState =
|
||||
| "none"
|
||||
| "ready"
|
||||
| "playing"
|
||||
| "paused"
|
||||
| "stopped"
|
||||
| "loading"
|
||||
| "buffering"
|
||||
| "error"
|
||||
| "ended";
|
||||
|
||||
export interface IPlaybackSnapshot {
|
||||
activeTrackIndex?: number | null;
|
||||
artist?: string | null;
|
||||
platform?: string | null;
|
||||
position: number;
|
||||
state: PlaybackAnomalyState;
|
||||
trackTitle?: string | null;
|
||||
trackUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface IPlaybackErrorSnapshot {
|
||||
activeTrackIndex?: number | null;
|
||||
artist?: string | null;
|
||||
code?: string | null;
|
||||
diagnostics?: Record<string, unknown> | null;
|
||||
message?: string | null;
|
||||
platform?: string | null;
|
||||
trackTitle?: string | null;
|
||||
trackUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface IPlaybackAnomalyReportBase {
|
||||
activeTrackIndex?: number | null;
|
||||
artist?: string | null;
|
||||
platform?: string | null;
|
||||
trackTitle?: string | null;
|
||||
trackUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface IPlaybackErrorReport extends IPlaybackAnomalyReportBase {
|
||||
activeTrackIndex?: number | null;
|
||||
code?: string | null;
|
||||
diagnostics?: Record<string, unknown> | null;
|
||||
message?: string | null;
|
||||
platform?: string | null;
|
||||
trackUrl?: string | null;
|
||||
type: "error";
|
||||
}
|
||||
|
||||
export interface IPlaybackStallReport extends IPlaybackAnomalyReportBase {
|
||||
position: number;
|
||||
stalledForMs: number;
|
||||
state: PlaybackAnomalyState;
|
||||
type: "stall";
|
||||
}
|
||||
|
||||
export type PlaybackAnomalyReport =
|
||||
| IPlaybackErrorReport
|
||||
| IPlaybackStallReport;
|
||||
|
||||
export interface ICreatePlaybackAnomalyMonitorOptions {
|
||||
cooldownMs: number;
|
||||
now?: () => number;
|
||||
report: (report: PlaybackAnomalyReport) => void | Promise<void>;
|
||||
stallThresholdMs: number;
|
||||
}
|
||||
|
||||
const ACTIVE_STALL_STATES = new Set<PlaybackAnomalyState>([
|
||||
"playing",
|
||||
"buffering",
|
||||
]);
|
||||
|
||||
export function createPlaybackAnomalyMonitor(
|
||||
options: ICreatePlaybackAnomalyMonitorOptions,
|
||||
) {
|
||||
const now = options.now ?? (() => Date.now());
|
||||
let lastPosition = 0;
|
||||
let lastPositionChangeAt = now();
|
||||
let lastState: PlaybackAnomalyState = "none";
|
||||
let lastTrackSignature = "";
|
||||
let lastReportedKey = "";
|
||||
let lastReportedAt = 0;
|
||||
|
||||
function canReport(key: string) {
|
||||
const current = now();
|
||||
if (
|
||||
key === lastReportedKey &&
|
||||
current - lastReportedAt < options.cooldownMs
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
lastReportedKey = key;
|
||||
lastReportedAt = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeText(value?: string | null) {
|
||||
const text = value?.trim();
|
||||
return text?.length ? text : undefined;
|
||||
}
|
||||
|
||||
function getTrackSignature(trackTitle?: string | null, artist?: string | null) {
|
||||
return `${normalizeText(trackTitle) ?? ""}::${normalizeText(artist) ?? ""}`;
|
||||
}
|
||||
|
||||
function emitReport(report: PlaybackAnomalyReport) {
|
||||
Promise.resolve(options.report(report)).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
recordPlaybackError(snapshot: IPlaybackErrorSnapshot) {
|
||||
const trackTitle = normalizeText(snapshot.trackTitle);
|
||||
const artist = normalizeText(snapshot.artist);
|
||||
const code = normalizeText(snapshot.code);
|
||||
const message = normalizeText(snapshot.message);
|
||||
const key = `error:${code ?? "unknown"}:${trackTitle ?? ""}:${artist ?? ""}`;
|
||||
if (!canReport(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitReport({
|
||||
activeTrackIndex: snapshot.activeTrackIndex ?? null,
|
||||
artist,
|
||||
code,
|
||||
diagnostics: snapshot.diagnostics ?? null,
|
||||
message,
|
||||
platform: normalizeText(snapshot.platform),
|
||||
trackTitle,
|
||||
trackUrl: normalizeText(snapshot.trackUrl),
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
|
||||
recordProgress(position: number) {
|
||||
if (position !== lastPosition) {
|
||||
lastPosition = position;
|
||||
lastPositionChangeAt = now();
|
||||
}
|
||||
},
|
||||
|
||||
recordSnapshot(snapshot: IPlaybackSnapshot) {
|
||||
const current = now();
|
||||
const trackTitle = normalizeText(snapshot.trackTitle);
|
||||
const artist = normalizeText(snapshot.artist);
|
||||
const trackSignature = getTrackSignature(trackTitle, artist);
|
||||
|
||||
if (trackSignature !== lastTrackSignature) {
|
||||
lastTrackSignature = trackSignature;
|
||||
lastPosition = snapshot.position;
|
||||
lastPositionChangeAt = current;
|
||||
lastState = snapshot.state;
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.position !== lastPosition) {
|
||||
lastPosition = snapshot.position;
|
||||
lastPositionChangeAt = current;
|
||||
lastState = snapshot.state;
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.state !== lastState) {
|
||||
lastState = snapshot.state;
|
||||
lastPositionChangeAt = current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACTIVE_STALL_STATES.has(snapshot.state)) {
|
||||
lastPositionChangeAt = current;
|
||||
return;
|
||||
}
|
||||
|
||||
const stalledForMs = current - lastPositionChangeAt;
|
||||
if (stalledForMs < options.stallThresholdMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `stall:${trackTitle ?? ""}:${artist ?? ""}:${snapshot.position}`;
|
||||
if (!canReport(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitReport({
|
||||
activeTrackIndex: snapshot.activeTrackIndex ?? null,
|
||||
artist,
|
||||
platform: normalizeText(snapshot.platform),
|
||||
position: snapshot.position,
|
||||
stalledForMs,
|
||||
state: snapshot.state,
|
||||
trackTitle,
|
||||
trackUrl: normalizeText(snapshot.trackUrl),
|
||||
type: "stall",
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
lastPosition = 0;
|
||||
lastPositionChangeAt = now();
|
||||
lastState = "none";
|
||||
lastTrackSignature = "";
|
||||
lastReportedAt = 0;
|
||||
lastReportedKey = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { buildPlaybackErrorDiagnostics } from "./playbackErrorDiagnostics";
|
||||
|
||||
describe("buildPlaybackErrorDiagnostics", () => {
|
||||
it("prefers active track details when available", () => {
|
||||
const diagnostics = buildPlaybackErrorDiagnostics({
|
||||
activeTrack: {
|
||||
$: "marker-1",
|
||||
artist: "Singer",
|
||||
id: "song-1",
|
||||
isInit: false,
|
||||
platform: "Music_Server",
|
||||
title: "Song",
|
||||
url: "https://example.com/song.flac",
|
||||
},
|
||||
activeTrackIndex: 0,
|
||||
currentMusic: {
|
||||
artist: "Current Singer",
|
||||
id: "song-current",
|
||||
platform: "Current",
|
||||
title: "Current Song",
|
||||
url: "https://example.com/current.mp3",
|
||||
},
|
||||
eventCode: "android-source",
|
||||
eventMessage: "Source error",
|
||||
persistedTrack: {
|
||||
artist: "Persisted Singer",
|
||||
id: "song-persisted",
|
||||
platform: "Persisted",
|
||||
title: "Persisted Song",
|
||||
url: "https://example.com/persisted.mp3",
|
||||
},
|
||||
playbackState: "playing",
|
||||
progress: {
|
||||
buffered: 42,
|
||||
duration: 180,
|
||||
position: 12,
|
||||
},
|
||||
queueTrack: {
|
||||
artist: "Queued Singer",
|
||||
id: "song-queue",
|
||||
platform: "Queued",
|
||||
title: "Queued Song",
|
||||
url: "https://example.com/queue.mp3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics).toEqual({
|
||||
activeTrackIndex: 0,
|
||||
artist: "Singer",
|
||||
diagnostics: {
|
||||
activeTrack: {
|
||||
artist: "Singer",
|
||||
id: "song-1",
|
||||
isInit: false,
|
||||
marker: "marker-1",
|
||||
platform: "Music_Server",
|
||||
title: "Song",
|
||||
url: "https://example.com/song.flac",
|
||||
},
|
||||
currentMusic: {
|
||||
artist: "Current Singer",
|
||||
id: "song-current",
|
||||
isInit: null,
|
||||
marker: null,
|
||||
platform: "Current",
|
||||
title: "Current Song",
|
||||
url: "https://example.com/current.mp3",
|
||||
},
|
||||
event: {
|
||||
code: "android-source",
|
||||
message: "Source error",
|
||||
},
|
||||
persistedTrack: {
|
||||
artist: "Persisted Singer",
|
||||
id: "song-persisted",
|
||||
isInit: null,
|
||||
marker: null,
|
||||
platform: "Persisted",
|
||||
title: "Persisted Song",
|
||||
url: "https://example.com/persisted.mp3",
|
||||
},
|
||||
playbackState: "playing",
|
||||
progress: {
|
||||
buffered: 42,
|
||||
duration: 180,
|
||||
position: 12,
|
||||
},
|
||||
queueTrack: {
|
||||
artist: "Queued Singer",
|
||||
id: "song-queue",
|
||||
isInit: null,
|
||||
marker: null,
|
||||
platform: "Queued",
|
||||
title: "Queued Song",
|
||||
url: "https://example.com/queue.mp3",
|
||||
},
|
||||
resolvedTrack: {
|
||||
artist: "Singer",
|
||||
id: "song-1",
|
||||
isInit: false,
|
||||
marker: "marker-1",
|
||||
platform: "Music_Server",
|
||||
title: "Song",
|
||||
url: "https://example.com/song.flac",
|
||||
},
|
||||
},
|
||||
platform: "Music_Server",
|
||||
trackTitle: "Song",
|
||||
trackUrl: "https://example.com/song.flac",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to queued track details when active track is missing", () => {
|
||||
const diagnostics = buildPlaybackErrorDiagnostics({
|
||||
activeTrack: null,
|
||||
activeTrackIndex: null,
|
||||
currentMusic: {
|
||||
artist: "Current Singer",
|
||||
id: "song-current",
|
||||
platform: "Current",
|
||||
title: "Current Song",
|
||||
url: "https://example.com/current.mp3",
|
||||
},
|
||||
eventMessage: "Source error",
|
||||
persistedTrack: {
|
||||
artist: "Persisted Singer",
|
||||
id: "song-persisted",
|
||||
platform: "Persisted",
|
||||
title: "Persisted Song",
|
||||
url: "https://example.com/persisted.mp3",
|
||||
},
|
||||
playbackState: "error",
|
||||
progress: null,
|
||||
queueTrack: {
|
||||
artist: "Queued Singer",
|
||||
id: "song-queue",
|
||||
platform: "Queued",
|
||||
title: "Queued Song",
|
||||
url: "https://example.com/queue.mp3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.artist).toBe("Queued Singer");
|
||||
expect(diagnostics.platform).toBe("Queued");
|
||||
expect(diagnostics.trackTitle).toBe("Queued Song");
|
||||
expect(diagnostics.trackUrl).toBe("https://example.com/queue.mp3");
|
||||
expect(diagnostics.diagnostics.resolvedTrack).toEqual(
|
||||
diagnostics.diagnostics.queueTrack,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
interface ITrackLike {
|
||||
$?: unknown;
|
||||
artist?: unknown;
|
||||
id?: unknown;
|
||||
isInit?: unknown;
|
||||
platform?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
}
|
||||
|
||||
interface IProgressLike {
|
||||
buffered?: number | null;
|
||||
duration?: number | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface IBuildPlaybackErrorDiagnosticsInput {
|
||||
activeTrack?: ITrackLike | null;
|
||||
activeTrackIndex?: number | null;
|
||||
currentMusic?: ITrackLike | null;
|
||||
eventCode?: string | null;
|
||||
eventMessage?: string | null;
|
||||
persistedTrack?: ITrackLike | null;
|
||||
playbackState?: string | null;
|
||||
progress?: IProgressLike | null;
|
||||
queueTrack?: ITrackLike | null;
|
||||
}
|
||||
|
||||
function normalizeText(value?: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTrack(track?: ITrackLike | null) {
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: normalizeText(track.artist),
|
||||
id: track.id ?? null,
|
||||
isInit: typeof track.isInit === "boolean" ? track.isInit : null,
|
||||
marker: normalizeText(track.$),
|
||||
platform: normalizeText(track.platform),
|
||||
title: normalizeText(track.title),
|
||||
url: normalizeText(track.url),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPlaybackErrorDiagnostics(
|
||||
input: IBuildPlaybackErrorDiagnosticsInput,
|
||||
) {
|
||||
const activeTrack = normalizeTrack(input.activeTrack);
|
||||
const queueTrack = normalizeTrack(input.queueTrack);
|
||||
const persistedTrack = normalizeTrack(input.persistedTrack);
|
||||
const currentMusic = normalizeTrack(input.currentMusic);
|
||||
const resolvedTrack =
|
||||
activeTrack ?? queueTrack ?? persistedTrack ?? currentMusic;
|
||||
|
||||
return {
|
||||
activeTrackIndex: input.activeTrackIndex ?? null,
|
||||
artist: resolvedTrack?.artist ?? null,
|
||||
diagnostics: {
|
||||
activeTrack,
|
||||
currentMusic,
|
||||
event: {
|
||||
code: normalizeText(input.eventCode),
|
||||
message: normalizeText(input.eventMessage),
|
||||
},
|
||||
persistedTrack,
|
||||
playbackState: normalizeText(input.playbackState),
|
||||
progress: {
|
||||
buffered: input.progress?.buffered ?? null,
|
||||
duration: input.progress?.duration ?? null,
|
||||
position: input.progress?.position ?? null,
|
||||
},
|
||||
queueTrack,
|
||||
resolvedTrack,
|
||||
},
|
||||
platform: resolvedTrack?.platform ?? null,
|
||||
trackTitle: resolvedTrack?.title ?? null,
|
||||
trackUrl: resolvedTrack?.url ?? null,
|
||||
};
|
||||
}
|
||||
+1
@@ -58,6 +58,7 @@ export interface ILanguageData {
|
||||
|
||||
// 检查更新相关
|
||||
"checkUpdate.error.latestVersion": string; // 当前已是最新版本
|
||||
"checkUpdate.error.checkFailed": string; // 检查更新失败
|
||||
|
||||
// 首页相关
|
||||
"home.recommendSheet": string; // 推荐歌单
|
||||
|
||||
@@ -83,7 +83,10 @@ describe("checkUpdate", () => {
|
||||
|
||||
const result = await checkUpdate();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
needUpdate: false,
|
||||
error: true,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
"http://10.0.0.2:18080/app/version.json",
|
||||
@@ -99,4 +102,58 @@ describe("checkUpdate", () => {
|
||||
expect(result).toBeUndefined();
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns latest state when remote version is not newer", async () => {
|
||||
mockGetByName.mockImplementation((name: string) => {
|
||||
if (name !== "Music_Server") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: "Music_Server",
|
||||
instance: {
|
||||
srcUrl: "http://192.168.1.10:18080/plugins/music_server.js",
|
||||
},
|
||||
};
|
||||
});
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
data: {
|
||||
version: "0.6.0",
|
||||
changeLog: [],
|
||||
download: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await checkUpdate();
|
||||
|
||||
expect(result).toEqual({
|
||||
needUpdate: false,
|
||||
data: {
|
||||
version: "0.6.0",
|
||||
changeLog: [],
|
||||
download: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure state when update metadata request fails", async () => {
|
||||
mockGetByName.mockImplementation((name: string) => {
|
||||
if (name !== "Music_Server") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: "Music_Server",
|
||||
instance: {
|
||||
srcUrl: "http://192.168.1.10:18080/plugins/music_server.js",
|
||||
},
|
||||
};
|
||||
});
|
||||
(axios.get as jest.Mock).mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await checkUpdate();
|
||||
|
||||
expect(result).toEqual({
|
||||
needUpdate: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const musicServerAppVersionPath = "/app/version.json";
|
||||
|
||||
interface IUpdateInfo {
|
||||
needUpdate: boolean;
|
||||
error?: boolean;
|
||||
data: {
|
||||
version: string;
|
||||
changeLog: string[];
|
||||
@@ -74,13 +75,14 @@ export default async function checkUpdate(): Promise<IUpdateInfo | undefined> {
|
||||
}
|
||||
try {
|
||||
const rawInfo = (await axios.get(updateUrl)).data;
|
||||
if (compare(rawInfo.version, currentVersion, ">")) {
|
||||
return {
|
||||
needUpdate: true,
|
||||
data: rawInfo,
|
||||
};
|
||||
}
|
||||
return {
|
||||
needUpdate: compare(rawInfo.version, currentVersion, ">"),
|
||||
data: rawInfo,
|
||||
};
|
||||
} catch {
|
||||
return;
|
||||
return {
|
||||
needUpdate: false,
|
||||
error: true,
|
||||
} as IUpdateInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const mockReadDir = jest.fn();
|
||||
const mockReadFile = jest.fn();
|
||||
|
||||
jest.mock("react-native-fs", () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
readDir: (...args: any[]) => mockReadDir(...args),
|
||||
readFile: (...args: any[]) => mockReadFile(...args),
|
||||
}));
|
||||
|
||||
jest.mock("@/constants/pathConst", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
logPath: "/mock/logs",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../core/appConfig.ts", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getConfig: jest.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/react-native-vdebug/src/log", () => ({
|
||||
addLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("react-native-logs", () => ({
|
||||
fileAsyncTransport: jest.fn(),
|
||||
logger: {
|
||||
createLogger: jest.fn(() => ({
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getErrorLogContent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2026-06-01T10:00:00+08:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes playback diagnostics from trace-log alongside error logs", async () => {
|
||||
mockReadDir.mockResolvedValue([
|
||||
{
|
||||
isFile: () => true,
|
||||
path: "/mock/logs/error-log-1-6-2026.log",
|
||||
},
|
||||
{
|
||||
isFile: () => true,
|
||||
path: "/mock/logs/trace-log.log",
|
||||
},
|
||||
]);
|
||||
mockReadFile.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith("error-log-1-6-2026.log")) {
|
||||
return "error-log-line\n";
|
||||
}
|
||||
if (path.endsWith("trace-log.log")) {
|
||||
return "[PlaybackService] playback-stall\n";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const { getErrorLogContent } = require("./log");
|
||||
const content = await getErrorLogContent();
|
||||
|
||||
expect(content).toContain("error-log-line");
|
||||
expect(content).toContain("[PlaybackService] playback-stall");
|
||||
});
|
||||
|
||||
it("includes fake-tail transition diagnostics from trace-log in app log view", async () => {
|
||||
mockReadDir.mockResolvedValue([
|
||||
{
|
||||
isFile: () => true,
|
||||
path: "/mock/logs/trace-log.log",
|
||||
},
|
||||
]);
|
||||
mockReadFile.mockResolvedValue(
|
||||
[
|
||||
'2026/6/2 15:44:22 | INFO : ',
|
||||
'{',
|
||||
' "desc": "[TrackPlayer] PlaybackActiveTrackChanged",',
|
||||
' "message": "{\\"url\\":\\"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav\\"}"',
|
||||
'}',
|
||||
'2026/6/2 15:44:22 | INFO : ',
|
||||
'{',
|
||||
' "desc": "[TrackPlayer] fake-tail-hit",',
|
||||
' "message": ""',
|
||||
'}',
|
||||
'2026/6/2 15:44:22 | INFO : ',
|
||||
'{',
|
||||
' "desc": "[TrackPlayer] handlePlayEndTransition:start",',
|
||||
' "message": "{\\"currentIndex\\":30}"',
|
||||
'}',
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const { getErrorLogContent } = require("./log");
|
||||
const content = await getErrorLogContent();
|
||||
|
||||
expect(content).toContain("[TrackPlayer] PlaybackActiveTrackChanged");
|
||||
expect(content).toContain("silent-tail.wav");
|
||||
expect(content).toContain("[TrackPlayer] fake-tail-hit");
|
||||
expect(content).toContain("[TrackPlayer] handlePlayEndTransition:start");
|
||||
});
|
||||
|
||||
it("keeps media source diagnostics written into error logs visible in app", async () => {
|
||||
mockReadDir.mockResolvedValue([
|
||||
{
|
||||
isFile: () => true,
|
||||
path: "/mock/logs/error-log-1-6-2026.log",
|
||||
},
|
||||
]);
|
||||
mockReadFile.mockResolvedValue(
|
||||
'[PluginManager] media-source-error {"platform":"TestPlugin","message":"NOT RETRY"}\n',
|
||||
);
|
||||
|
||||
const { getErrorLogContent } = require("./log");
|
||||
const content = await getErrorLogContent();
|
||||
|
||||
expect(content).toContain("[PluginManager] media-source-error");
|
||||
expect(content).toContain("NOT RETRY");
|
||||
});
|
||||
|
||||
it("includes playback error and remote previous diagnostics from trace-log in app log view", async () => {
|
||||
mockReadDir.mockResolvedValue([
|
||||
{
|
||||
isFile: () => true,
|
||||
path: "/mock/logs/trace-log.log",
|
||||
},
|
||||
]);
|
||||
mockReadFile.mockResolvedValue(
|
||||
[
|
||||
'2026/6/10 20:52:52 | ERROR : ',
|
||||
'{',
|
||||
' "desc": "[TrackPlayer] PlaybackError",',
|
||||
' "message": "{\\"message\\":\\"Source error\\"}"',
|
||||
'}',
|
||||
'2026/6/10 20:52:53 | INFO : ',
|
||||
'{',
|
||||
' "desc": "[PlaybackService] RemotePrevious",',
|
||||
' "message": "{\\"previousMusic\\":{\\"id\\":\\"song-1\\"}}"',
|
||||
'}',
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const { getErrorLogContent } = require("./log");
|
||||
const content = await getErrorLogContent();
|
||||
|
||||
expect(content).toContain("[TrackPlayer] PlaybackError");
|
||||
expect(content).toContain("Source error");
|
||||
expect(content).toContain("[PlaybackService] RemotePrevious");
|
||||
expect(content).toContain("song-1");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,27 @@ const traceConfig = {
|
||||
|
||||
const log = logger.createLogger(config);
|
||||
const traceLogger = logger.createLogger(traceConfig);
|
||||
const appVisibleTraceKeywords = [
|
||||
"[PlaybackService] playback-error",
|
||||
"[PlaybackService] playback-stall",
|
||||
"[PlaybackService] RemotePrevious",
|
||||
"[TrackPlayer] PlaybackActiveTrackChanged",
|
||||
"[TrackPlayer] PlaybackError",
|
||||
"[TrackPlayer] PlaybackError:decision",
|
||||
"[TrackPlayer] fake-tail-hit",
|
||||
"[TrackPlayer] fake-tail-queue-ended",
|
||||
"[TrackPlayer] handlePlayEndTransition:start",
|
||||
"[TrackPlayer] handlePlayEndTransition:done",
|
||||
"[TrackPlayer] PlaybackQueueEnded",
|
||||
"[TrackPlayer] play:failed",
|
||||
"[TrackPlayer] play:set-proposed-queue",
|
||||
"[TrackPlayer] play:source-ready",
|
||||
"[TrackPlayer] play:start",
|
||||
"[TrackPlayer] setTrackSource",
|
||||
"[TrackPlayer] setTrackSource:play-called",
|
||||
"[TrackPlayer] skipToNext",
|
||||
"[TrackPlayer] skipToPrevious",
|
||||
];
|
||||
|
||||
export function trace(
|
||||
desc: string,
|
||||
@@ -97,7 +118,6 @@ export async function clearLog() {
|
||||
export async function getErrorLogContent() {
|
||||
try {
|
||||
const files = await readDir(pathConst.logPath);
|
||||
console.log(files);
|
||||
const today = new Date();
|
||||
// 两天的错误日志
|
||||
const yesterday = new Date();
|
||||
@@ -120,6 +140,9 @@ export async function getErrorLogContent() {
|
||||
}-${yesterday.getFullYear()}.log`,
|
||||
),
|
||||
);
|
||||
const traceLog = files.find(
|
||||
_ => _.isFile() && _.path.endsWith("trace-log.log"),
|
||||
);
|
||||
let logContent = "";
|
||||
if (todayLog) {
|
||||
logContent += await readFile(todayLog.path, "utf8");
|
||||
@@ -127,6 +150,23 @@ export async function getErrorLogContent() {
|
||||
if (yesterdayLog) {
|
||||
logContent += await readFile(yesterdayLog.path, "utf8");
|
||||
}
|
||||
if (traceLog) {
|
||||
const traceLogContent = await readFile(traceLog.path, "utf8");
|
||||
const playbackDiagnostics = traceLogContent
|
||||
.split(
|
||||
/\r?\n(?=(?:\d{1,2}\/\d{1,2}\/\d{4},|\d{4}\/\d{1,2}\/\d{1,2}\s))/,
|
||||
)
|
||||
.filter(
|
||||
entry =>
|
||||
appVisibleTraceKeywords.some(keyword =>
|
||||
entry.includes(keyword),
|
||||
),
|
||||
)
|
||||
.join("\n");
|
||||
if (playbackDiagnostics) {
|
||||
logContent += `${logContent ? "\n" : ""}${playbackDiagnostics}`;
|
||||
}
|
||||
}
|
||||
return logContent;
|
||||
} catch {
|
||||
return "";
|
||||
@@ -143,6 +183,17 @@ export function errorLog(desc: string, message: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export function persistErrorLog(desc: string, message: any) {
|
||||
try {
|
||||
log.error({
|
||||
desc,
|
||||
message,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
trace(desc, message, "error");
|
||||
}
|
||||
|
||||
export function devLog(
|
||||
method: "log" | "error" | "warn" | "info",
|
||||
...args: any[]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { resolvePagedMusicList } from "./resolvePagedMusicList";
|
||||
|
||||
describe("resolvePagedMusicList", () => {
|
||||
it("loads remaining pages and returns the complete music list", async () => {
|
||||
const initialMusicList = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `song-${index + 1}`,
|
||||
})) as IMusic.IMusicItem[];
|
||||
|
||||
const loadPage = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
musicList: Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `song-${index + 61}`,
|
||||
})),
|
||||
isEnd: true,
|
||||
});
|
||||
|
||||
const result = await resolvePagedMusicList({
|
||||
initialMusicList,
|
||||
nextPage: 2,
|
||||
isEnd: false,
|
||||
loadPage,
|
||||
});
|
||||
|
||||
expect(loadPage).toHaveBeenCalledTimes(1);
|
||||
expect(loadPage).toHaveBeenCalledWith(2);
|
||||
expect(result).toHaveLength(100);
|
||||
expect(result[0]?.id).toBe("song-1");
|
||||
expect(result[99]?.id).toBe("song-100");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
interface IResolvePagedMusicListProps {
|
||||
initialMusicList: IMusic.IMusicItem[];
|
||||
nextPage: number;
|
||||
isEnd: boolean;
|
||||
loadPage: (
|
||||
page: number,
|
||||
) => Promise<
|
||||
| {
|
||||
musicList?: IMusic.IMusicItem[];
|
||||
isEnd?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export async function resolvePagedMusicList(
|
||||
props: IResolvePagedMusicListProps,
|
||||
) {
|
||||
const { initialMusicList, nextPage, isEnd, loadPage } = props;
|
||||
|
||||
if (isEnd) {
|
||||
return initialMusicList;
|
||||
}
|
||||
|
||||
const mergedMusicList = [...initialMusicList];
|
||||
let currentPage = nextPage;
|
||||
let reachedEnd: boolean = isEnd;
|
||||
|
||||
while (!reachedEnd) {
|
||||
const result = await loadPage(currentPage);
|
||||
if (!result) {
|
||||
throw new Error("Failed to load complete music list");
|
||||
}
|
||||
if (result.musicList?.length) {
|
||||
mergedMusicList.push(...result.musicList);
|
||||
}
|
||||
reachedEnd = result.isEnd === false ? false : true;
|
||||
currentPage += 1;
|
||||
}
|
||||
|
||||
return mergedMusicList;
|
||||
}
|
||||
Reference in New Issue
Block a user