feat: sync catalog, server, and MusicFree updates
This commit is contained in:
@@ -114,7 +114,7 @@ static def getVersion() {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
def appVersion = getVersion()
|
def appVersion = getVersion()
|
||||||
def appVersionCode = 400012
|
def appVersionCode = 400015
|
||||||
|
|
||||||
|
|
||||||
android {
|
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.util.DisplayMetrics
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.view.WindowManager
|
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 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.Arguments
|
||||||
import com.facebook.react.bridge.Promise
|
import com.facebook.react.bridge.Promise
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
@@ -23,6 +28,8 @@ import kotlin.system.exitProcess
|
|||||||
class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
||||||
|
|
||||||
private val reactContext: ReactApplicationContext = context;
|
private val reactContext: ReactApplicationContext = context;
|
||||||
|
private val playbackDiagnosticChannelId = "musicfree_playback_diag"
|
||||||
|
private val playbackDiagnosticNotificationId = 42042
|
||||||
|
|
||||||
override fun getName() = "NativeUtils"
|
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)
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||||
fun getWindowDimensions(): WritableMap {
|
fun getWindowDimensions(): WritableMap {
|
||||||
val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "MusicFree",
|
"name": "MusicFree",
|
||||||
"version": "0.6.3",
|
"version": "0.6.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL",
|
"license": "AGPL",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"connect-mumu": "adb kill-server & adb connect localhost:7555",
|
"connect-mumu": "adb kill-server & adb connect localhost:7555",
|
||||||
"build-android": "cd .\\android\\ && .\\gradlew assembleRelease",
|
"build-android": "cd .\\android\\ && .\\gradlew assembleRelease",
|
||||||
"generate-assets": "node ./generator/generate-assets.mjs",
|
"generate-assets": "node ./generator/generate-assets.mjs",
|
||||||
|
"postinstall": "patch-package",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
"husky": "^9.1.4",
|
"husky": "^9.1.4",
|
||||||
"jest": "^29.6.3",
|
"jest": "^29.6.3",
|
||||||
"lint-staged": "^15.2.7",
|
"lint-staged": "^15.2.7",
|
||||||
|
"patch-package": "^8.0.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"react-native-svg-transformer": "^1.5.0",
|
"react-native-svg-transformer": "^1.5.0",
|
||||||
"react-test-renderer": "18.3.1",
|
"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;
|
musicList: IMusic.IMusicItem[] | null;
|
||||||
canStar?: boolean;
|
canStar?: boolean;
|
||||||
musicSheet?: IMusic.IMusicSheetItem | null;
|
musicSheet?: IMusic.IMusicSheetItem | null;
|
||||||
|
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||||
|
addToSheetCount?: number;
|
||||||
}
|
}
|
||||||
export default function (props: IProps) {
|
export default function (props: IProps) {
|
||||||
const { musicList, canStar, musicSheet } = props;
|
const { musicList, canStar, musicSheet, resolveMusicListBeforeAdd, addToSheetCount } =
|
||||||
|
props;
|
||||||
|
|
||||||
const sheetName = musicSheet?.title;
|
const sheetName = musicSheet?.title;
|
||||||
const sheetId = musicSheet?.id;
|
const sheetId = musicSheet?.id;
|
||||||
@@ -86,6 +89,8 @@ export default function (props: IProps) {
|
|||||||
showPanel("AddToMusicSheet", {
|
showPanel("AddToMusicSheet", {
|
||||||
musicItem: musicList ?? [],
|
musicItem: musicList ?? [],
|
||||||
newSheetDefaultName: sheetName,
|
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;
|
paddingLeft?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipMusicItem(direction: number) {
|
export function skipMusicItem(direction: number) {
|
||||||
if (direction === -1) {
|
if (direction === -1) {
|
||||||
TrackPlayer.skipToNext();
|
TrackPlayer.skipToNext();
|
||||||
} else if (direction === 1) {
|
} else if (direction === 1) {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface IHeaderProps {
|
|||||||
musicSheet: IMusic.IMusicSheetItem | null;
|
musicSheet: IMusic.IMusicSheetItem | null;
|
||||||
musicList: IMusic.IMusicItem[] | null;
|
musicList: IMusic.IMusicItem[] | null;
|
||||||
canStar?: boolean;
|
canStar?: boolean;
|
||||||
|
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||||
}
|
}
|
||||||
export default function Header(props: IHeaderProps) {
|
export default function Header(props: IHeaderProps) {
|
||||||
const { musicSheet, musicList, canStar } = props;
|
const { musicSheet, musicList, canStar, resolveMusicListBeforeAdd } = props;
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
const [maxLines, setMaxLines] = useState<number | undefined>(6);
|
const [maxLines, setMaxLines] = useState<number | undefined>(6);
|
||||||
@@ -74,6 +75,8 @@ export default function Header(props: IHeaderProps) {
|
|||||||
canStar={canStar}
|
canStar={canStar}
|
||||||
musicList={musicList}
|
musicList={musicList}
|
||||||
musicSheet={musicSheet}
|
musicSheet={musicSheet}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
|
addToSheetCount={musicSheet?.worksNum ?? musicList?.length}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,15 +13,24 @@ import { RequestStateCode } from "@/constants/commonConst";
|
|||||||
interface IMusicListProps {
|
interface IMusicListProps {
|
||||||
sheetInfo: IMusic.IMusicSheetItem | null;
|
sheetInfo: IMusic.IMusicSheetItem | null;
|
||||||
musicList?: IMusic.IMusicItem[] | null;
|
musicList?: IMusic.IMusicItem[] | null;
|
||||||
// 是否可收藏
|
// 鏄惁鍙敹钘?
|
||||||
canStar?: boolean;
|
canStar?: boolean;
|
||||||
// 状态
|
// 鐘舵€?
|
||||||
state: RequestStateCode;
|
state: RequestStateCode;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
|
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||||
}
|
}
|
||||||
export default function SheetMusicList(props: IMusicListProps) {
|
export default function SheetMusicList(props: IMusicListProps) {
|
||||||
const { sheetInfo, musicList, canStar, state, onRetry, onLoadMore } = props;
|
const {
|
||||||
|
sheetInfo,
|
||||||
|
musicList,
|
||||||
|
canStar,
|
||||||
|
state,
|
||||||
|
onRetry,
|
||||||
|
onLoadMore,
|
||||||
|
resolveMusicListBeforeAdd,
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={globalStyle.fwflex1}>
|
<View style={globalStyle.fwflex1}>
|
||||||
@@ -36,6 +45,7 @@ export default function SheetMusicList(props: IMusicListProps) {
|
|||||||
canStar={canStar}
|
canStar={canStar}
|
||||||
musicSheet={sheetInfo}
|
musicSheet={sheetInfo}
|
||||||
musicList={musicList}
|
musicList={musicList}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
|
|||||||
@@ -11,17 +11,26 @@ interface IMusicSheetPageProps {
|
|||||||
navTitle: string;
|
navTitle: string;
|
||||||
sheetInfo: ICommon.WithMusicList<IMusic.IMusicSheetItemBase> | null;
|
sheetInfo: ICommon.WithMusicList<IMusic.IMusicSheetItemBase> | null;
|
||||||
musicList?: IMusic.IMusicItem[] | null;
|
musicList?: IMusic.IMusicItem[] | null;
|
||||||
// 是否可收藏
|
// 鏄惁鍙敹钘?
|
||||||
canStar?: boolean;
|
canStar?: boolean;
|
||||||
// 状态
|
// 鐘舵€?
|
||||||
state: RequestStateCode;
|
state: RequestStateCode;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
|
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MusicSheetPage(props: IMusicSheetPageProps) {
|
export default function MusicSheetPage(props: IMusicSheetPageProps) {
|
||||||
const { navTitle, sheetInfo, musicList, canStar, onLoadMore, onRetry, state } =
|
const {
|
||||||
props;
|
navTitle,
|
||||||
|
sheetInfo,
|
||||||
|
musicList,
|
||||||
|
canStar,
|
||||||
|
onLoadMore,
|
||||||
|
onRetry,
|
||||||
|
state,
|
||||||
|
resolveMusicListBeforeAdd,
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VerticalSafeAreaView style={globalStyle.fwflex1}>
|
<VerticalSafeAreaView style={globalStyle.fwflex1}>
|
||||||
@@ -37,6 +46,7 @@ export default function MusicSheetPage(props: IMusicSheetPageProps) {
|
|||||||
state={state}
|
state={state}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
/>
|
/>
|
||||||
<MusicBar />
|
<MusicBar />
|
||||||
</VerticalSafeAreaView>
|
</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 { StyleSheet, View } from "react-native";
|
||||||
import rpx, { vmax } from "@/utils/rpx";
|
import rpx, { vmax } from "@/utils/rpx";
|
||||||
import ListItem from "@/components/base/listItem";
|
import ListItem from "@/components/base/listItem";
|
||||||
@@ -12,20 +12,57 @@ import { hidePanel, showPanel } from "../usePanel";
|
|||||||
import PanelHeader from "../base/panelHeader";
|
import PanelHeader from "../base/panelHeader";
|
||||||
import MusicSheet, { useSheetsBase } from "@/core/musicSheet";
|
import MusicSheet, { useSheetsBase } from "@/core/musicSheet";
|
||||||
import { useI18N } from "@/core/i18n";
|
import { useI18N } from "@/core/i18n";
|
||||||
|
import { showDialog } from "@/components/dialogs/useDialog";
|
||||||
|
|
||||||
interface IAddToMusicSheetProps {
|
interface IAddToMusicSheetProps {
|
||||||
musicItem: IMusic.IMusicItem | IMusic.IMusicItem[];
|
musicItem: IMusic.IMusicItem | IMusic.IMusicItem[];
|
||||||
// 如果是新建歌单,可以传入一个默认的名称
|
resolveMusicItem?: () => Promise<IMusic.IMusicItem[]>;
|
||||||
|
displayCount?: number;
|
||||||
|
// 濡傛灉鏄柊寤烘瓕鍗曪紝鍙互浼犲叆涓€涓粯璁ょ殑鍚嶇О
|
||||||
newSheetDefaultName?: string;
|
newSheetDefaultName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
||||||
const sheets = useSheetsBase();
|
const sheets = useSheetsBase();
|
||||||
|
|
||||||
const { musicItem = [], newSheetDefaultName } = props ?? {};
|
const {
|
||||||
|
musicItem = [],
|
||||||
|
newSheetDefaultName,
|
||||||
|
resolveMusicItem,
|
||||||
|
displayCount,
|
||||||
|
} = props ?? {};
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
const { t } = useI18N();
|
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 (
|
return (
|
||||||
<PanelBase
|
<PanelBase
|
||||||
renderBody={() => (
|
renderBody={() => (
|
||||||
@@ -34,7 +71,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
|||||||
hideButtons
|
hideButtons
|
||||||
title={
|
title={
|
||||||
t("panel.addToMusicSheet.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,
|
defaultName: newSheetDefaultName,
|
||||||
async onSheetCreated(sheetId) {
|
async onSheetCreated(sheetId) {
|
||||||
try {
|
try {
|
||||||
await MusicSheet.addMusic(
|
await addMusicToSheet(sheetId);
|
||||||
sheetId,
|
|
||||||
musicItem,
|
|
||||||
);
|
|
||||||
Toast.success(
|
|
||||||
t("panel.addToMusicSheet.toast.success"),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
Toast.warn(
|
Toast.warn(
|
||||||
t("panel.addToMusicSheet.toast.fail"),
|
t("panel.addToMusicSheet.toast.fail"),
|
||||||
@@ -69,7 +100,9 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
|||||||
},
|
},
|
||||||
onCancel() {
|
onCancel() {
|
||||||
showPanel("AddToMusicSheet", {
|
showPanel("AddToMusicSheet", {
|
||||||
musicItem: musicItem,
|
musicItem,
|
||||||
|
resolveMusicItem,
|
||||||
|
displayCount,
|
||||||
newSheetDefaultName,
|
newSheetDefaultName,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -86,16 +119,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
|
|||||||
withHorizontalPadding
|
withHorizontalPadding
|
||||||
key={`${sheet.id}`}
|
key={`${sheet.id}`}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
try {
|
await addMusicToSheet(sheet.id);
|
||||||
await MusicSheet.addMusic(
|
|
||||||
sheet.id,
|
|
||||||
musicItem,
|
|
||||||
);
|
|
||||||
hidePanel();
|
|
||||||
Toast.success(t("panel.addToMusicSheet.toast.success"));
|
|
||||||
} catch {
|
|
||||||
Toast.warn(t("panel.addToMusicSheet.toast.fail"));
|
|
||||||
}
|
|
||||||
}}>
|
}}>
|
||||||
<ListItem.ListItemImage
|
<ListItem.ListItemImage
|
||||||
uri={sheet.coverImg}
|
uri={sheet.coverImg}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"sidebar.languageSettings": "Language Settings",
|
"sidebar.languageSettings": "Language Settings",
|
||||||
"sidebar.downloadManager": "Download Manager",
|
"sidebar.downloadManager": "Download Manager",
|
||||||
"checkUpdate.error.latestVersion": "Already the latest version",
|
"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.recommendSheet": "Recommended Playlists",
|
||||||
"home.topList": "Charts",
|
"home.topList": "Charts",
|
||||||
"home.playHistory": "Play History",
|
"home.playHistory": "Play History",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"sidebar.languageSettings": "语言设置",
|
"sidebar.languageSettings": "语言设置",
|
||||||
"sidebar.downloadManager": "下载管理",
|
"sidebar.downloadManager": "下载管理",
|
||||||
"checkUpdate.error.latestVersion": "当前已是最新版本",
|
"checkUpdate.error.latestVersion": "当前已是最新版本",
|
||||||
|
"checkUpdate.error.checkFailed": "检查更新失败,请确认更新源可访问后重试",
|
||||||
"home.recommendSheet": "推荐歌单",
|
"home.recommendSheet": "推荐歌单",
|
||||||
"home.topList": "榜单",
|
"home.topList": "榜单",
|
||||||
"home.playHistory": "播放历史",
|
"home.playHistory": "播放历史",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"sidebar.languageSettings": "語言設定",
|
"sidebar.languageSettings": "語言設定",
|
||||||
"sidebar.downloadManager": "下載管理",
|
"sidebar.downloadManager": "下載管理",
|
||||||
"checkUpdate.error.latestVersion": "當前已是最新版本",
|
"checkUpdate.error.latestVersion": "當前已是最新版本",
|
||||||
|
"checkUpdate.error.checkFailed": "檢查更新失敗,請確認更新來源可存取後重試",
|
||||||
"home.recommendSheet": "推薦歌單",
|
"home.recommendSheet": "推薦歌單",
|
||||||
"home.topList": "榜單",
|
"home.topList": "榜單",
|
||||||
"home.playHistory": "播放歷史",
|
"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 RNFS, { exists, readFile, stat, writeFile } from "react-native-fs";
|
||||||
import { URL } from "react-native-url-polyfill";
|
import { URL } from "react-native-url-polyfill";
|
||||||
import * as webdav from "webdav";
|
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 Network from "../../utils/network";
|
||||||
import LocalMusicSheet from "../localMusicSheet";
|
import LocalMusicSheet from "../localMusicSheet";
|
||||||
import MediaCache from "../mediaCache";
|
import MediaCache from "../mediaCache";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pathConst from "@/constants/pathConst";
|
|||||||
import { MusicRepeatMode } from "@/constants/repeatModeConst";
|
import { MusicRepeatMode } from "@/constants/repeatModeConst";
|
||||||
import delay from "@/utils/delay";
|
import delay from "@/utils/delay";
|
||||||
import getUrlExt from "@/utils/getUrlExt";
|
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 { createMediaIndexMap } from "@/utils/mediaIndexMap";
|
||||||
import {
|
import {
|
||||||
getLocalPath,
|
getLocalPath,
|
||||||
@@ -36,11 +36,13 @@ import {
|
|||||||
PlaybackErrorAction,
|
PlaybackErrorAction,
|
||||||
decidePlaybackErrorAction,
|
decidePlaybackErrorAction,
|
||||||
} from "./playbackErrorDecision";
|
} from "./playbackErrorDecision";
|
||||||
|
import { shouldHandlePlaybackQueueEnded } from "./playbackTransitionDecision";
|
||||||
import {
|
import {
|
||||||
FAKE_TAIL_SILENT_WAV_BASE64,
|
FAKE_TAIL_SILENT_WAV_BASE64,
|
||||||
resolveFakeTailAudioPath,
|
resolveFakeTailAudioPath,
|
||||||
shouldRewriteFakeTailAudio,
|
shouldRewriteFakeTailAudio,
|
||||||
} from "./fakeTailAudio";
|
} from "./fakeTailAudio";
|
||||||
|
import { buildInvalidSourceLogPayload } from "./playbackFailureLog";
|
||||||
|
|
||||||
import { TrackPlayerEvents } from "@/core.defination/trackPlayer";
|
import { TrackPlayerEvents } from "@/core.defination/trackPlayer";
|
||||||
import type { IAppConfig } from "@/types/core/config";
|
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;
|
this.serviceInited = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +757,13 @@ class TrackPlayer extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
} else if (message === PlayFailReason.INVALID_SOURCE) {
|
} else if (message === PlayFailReason.INVALID_SOURCE) {
|
||||||
trace("Invalid source, playback failed");
|
trace("Invalid source, playback failed");
|
||||||
|
persistErrorLog(
|
||||||
|
"[TrackPlayer] invalid-source",
|
||||||
|
buildInvalidSourceLogPayload(
|
||||||
|
musicItem,
|
||||||
|
!this.configService.getConfig("basic.autoStopWhenError"),
|
||||||
|
),
|
||||||
|
);
|
||||||
await this.handlePlayFail();
|
await this.handlePlayFail();
|
||||||
} else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) {
|
} else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) {
|
||||||
// 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓?
|
// 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓?
|
||||||
@@ -754,11 +795,18 @@ class TrackPlayer extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetMusic = this.getPlayListMusicAt(this.currentIndex + 1);
|
||||||
forceTrace("[TrackPlayer] skipToNext", {
|
forceTrace("[TrackPlayer] skipToNext", {
|
||||||
currentIndex: this.currentIndex,
|
currentIndex: this.currentIndex,
|
||||||
repeatMode: this.repeatMode,
|
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> {
|
async skipToPrevious(): Promise<void> {
|
||||||
@@ -767,14 +815,20 @@ class TrackPlayer extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetMusic = this.getPlayListMusicAt(
|
||||||
|
this.currentIndex === -1 ? 0 : this.currentIndex - 1,
|
||||||
|
);
|
||||||
forceTrace("[TrackPlayer] skipToPrevious", {
|
forceTrace("[TrackPlayer] skipToPrevious", {
|
||||||
currentIndex: this.currentIndex,
|
currentIndex: this.currentIndex,
|
||||||
repeatMode: this.repeatMode,
|
repeatMode: this.repeatMode,
|
||||||
|
targetMusic: targetMusic
|
||||||
|
? {
|
||||||
|
id: targetMusic.id,
|
||||||
|
platform: targetMusic.platform,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
await this.play(
|
await this.play(targetMusic, true);
|
||||||
this.getPlayListMusicAt(this.currentIndex === -1 ? 0 : this.currentIndex - 1),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeQuality(newQuality: IMusic.IQualityKey): Promise<boolean> {
|
async changeQuality(newQuality: IMusic.IQualityKey): Promise<boolean> {
|
||||||
@@ -1335,4 +1389,3 @@ enum PlayFailReason {
|
|||||||
|
|
||||||
const trackPlayer = new TrackPlayer();
|
const trackPlayer = new TrackPlayer();
|
||||||
export default 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 RNTrackPlayer, { AppKilledPlaybackBehavior, Capability } from "react-native-track-player";
|
||||||
import i18n from "@/core/i18n";
|
import i18n from "@/core/i18n";
|
||||||
import bootstrapAtom from "./bootstrap.atom";
|
import bootstrapAtom from "./bootstrap.atom";
|
||||||
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions";
|
import { applyTrackPlayerOptions } from "./trackPlayerOptions";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
|
|
||||||
|
|
||||||
@@ -164,14 +164,14 @@ export async function initTrackPlayer(logger?: IPerfLogger) {
|
|||||||
}
|
}
|
||||||
logger?.mark("加载播放器");
|
logger?.mark("加载播放器");
|
||||||
|
|
||||||
await RNTrackPlayer.updateOptions({
|
await applyTrackPlayerOptions({
|
||||||
icon: ImgAsset.logoTransparent,
|
showExitOnNotification:
|
||||||
progressUpdateEventInterval: 1,
|
|
||||||
...getTrackPlayerOptionPayload(
|
|
||||||
!!Config.getConfig("basic.showExitOnNotification"),
|
!!Config.getConfig("basic.showExitOnNotification"),
|
||||||
Capability,
|
Capability,
|
||||||
AppKilledPlaybackBehavior,
|
AppKilledPlaybackBehavior,
|
||||||
),
|
updateOptions: RNTrackPlayer.updateOptions,
|
||||||
|
icon: ImgAsset.logoTransparent,
|
||||||
|
progressUpdateEventInterval: 1,
|
||||||
});
|
});
|
||||||
logger?.mark("播放器初始化完成");
|
logger?.mark("播放器初始化完成");
|
||||||
trace("播放器初始化完成");
|
trace("播放器初始化完成");
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions";
|
import {
|
||||||
|
applyTrackPlayerOptions,
|
||||||
|
getTrackPlayerOptionPayload,
|
||||||
|
} from "./trackPlayerOptions";
|
||||||
|
|
||||||
describe("getTrackPlayerOptionPayload", () => {
|
describe("getTrackPlayerOptionPayload", () => {
|
||||||
const Capability = {
|
const Capability = {
|
||||||
@@ -12,6 +15,8 @@ describe("getTrackPlayerOptionPayload", () => {
|
|||||||
|
|
||||||
const AppKilledPlaybackBehavior = {
|
const AppKilledPlaybackBehavior = {
|
||||||
ContinuePlayback: "ContinuePlayback",
|
ContinuePlayback: "ContinuePlayback",
|
||||||
|
StopPlaybackAndRemoveNotification:
|
||||||
|
"StopPlaybackAndRemoveNotification",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
it("keeps the playback service foreground across short track transition gaps", () => {
|
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.stopForegroundGracePeriod).toBe(30);
|
||||||
expect(options.android.appKilledPlaybackBehavior).toBe(
|
expect(options.android.appKilledPlaybackBehavior).toBe(
|
||||||
AppKilledPlaybackBehavior.ContinuePlayback,
|
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
|
||||||
);
|
);
|
||||||
expect(options.android.alwaysPauseOnInterruption).toBe(false);
|
expect(options.android.alwaysPauseOnInterruption).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -44,5 +49,34 @@ describe("getTrackPlayerOptionPayload", () => {
|
|||||||
expect(withoutStop.capabilities).not.toContain(Capability.Stop);
|
expect(withoutStop.capabilities).not.toContain(Capability.Stop);
|
||||||
expect(withStop.notificationCapabilities).toContain(Capability.SeekTo);
|
expect(withStop.notificationCapabilities).toContain(Capability.SeekTo);
|
||||||
expect(withoutStop.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 = {
|
type AppKilledPlaybackBehaviorLike = {
|
||||||
ContinuePlayback: unknown;
|
ContinuePlayback: unknown;
|
||||||
|
StopPlaybackAndRemoveNotification: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateOptionsFn = (options: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
export function getTrackPlayerOptionPayload(
|
export function getTrackPlayerOptionPayload(
|
||||||
showExitOnNotification: boolean,
|
showExitOnNotification: boolean,
|
||||||
Capability: CapabilityLike,
|
Capability: CapabilityLike,
|
||||||
@@ -30,16 +33,58 @@ export function getTrackPlayerOptionPayload(
|
|||||||
Capability.SkipToNext,
|
Capability.SkipToNext,
|
||||||
Capability.SkipToPrevious,
|
Capability.SkipToPrevious,
|
||||||
];
|
];
|
||||||
|
const notificationCapabilities = showExitOnNotification
|
||||||
|
? [
|
||||||
|
Capability.Play,
|
||||||
|
Capability.SkipToNext,
|
||||||
|
Capability.SkipToPrevious,
|
||||||
|
Capability.Stop,
|
||||||
|
Capability.SeekTo,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
Capability.Play,
|
||||||
|
Capability.SkipToNext,
|
||||||
|
Capability.SkipToPrevious,
|
||||||
|
Capability.SeekTo,
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
alwaysPauseOnInterruption: false,
|
alwaysPauseOnInterruption: false,
|
||||||
appKilledPlaybackBehavior:
|
appKilledPlaybackBehavior:
|
||||||
AppKilledPlaybackBehavior.ContinuePlayback,
|
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
|
||||||
stopForegroundGracePeriod: 30,
|
stopForegroundGracePeriod: 30,
|
||||||
},
|
},
|
||||||
capabilities,
|
capabilities,
|
||||||
compactCapabilities: capabilities,
|
compactCapabilities: notificationCapabilities,
|
||||||
notificationCapabilities: [...capabilities, Capability.SeekTo],
|
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) {
|
if (updateInfo?.needUpdate) {
|
||||||
const { data } = updateInfo;
|
const { data } = updateInfo;
|
||||||
const skipVersion = PersistStatus.get("app.skipVersion");
|
const skipVersion = PersistStatus.get("app.skipVersion");
|
||||||
console.log(skipVersion, data);
|
|
||||||
if (
|
if (
|
||||||
checkSkip &&
|
checkSkip &&
|
||||||
skipVersion &&
|
skipVersion &&
|
||||||
@@ -28,8 +27,13 @@ export const checkUpdateAndShowResult = (
|
|||||||
fromUrl: data.download[0],
|
fromUrl: data.download[0],
|
||||||
backUrl: data.download[1],
|
backUrl: data.download[1],
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
|
if (updateInfo?.error) {
|
||||||
|
Toast.warn(i18n.t("checkUpdate.error.checkFailed"));
|
||||||
|
} else {
|
||||||
Toast.success(i18n.t("checkUpdate.error.latestVersion"));
|
Toast.success(i18n.t("checkUpdate.error.latestVersion"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ interface INativeUtils extends NativeModule {
|
|||||||
requestStoragePermission: () => void;
|
requestStoragePermission: () => void;
|
||||||
isIgnoringBatteryOptimizations: () => Promise<boolean>;
|
isIgnoringBatteryOptimizations: () => Promise<boolean>;
|
||||||
requestIgnoreBatteryOptimizations: () => 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
|
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 { RequestStateCode } from "@/constants/commonConst";
|
||||||
import PluginManager from "@/core/pluginManager";
|
import PluginManager from "@/core/pluginManager";
|
||||||
|
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export default function useAlbumDetail(
|
export default function useAlbumDetail(
|
||||||
originalAlbumItem: IAlbum.IAlbumItem | null,
|
originalAlbumItem: IAlbum.IAlbumItem | null,
|
||||||
) {
|
) {
|
||||||
const currentPageRef = useRef(1);
|
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>(
|
const [albumItem, setAlbumItem] = useState<IAlbum.IAlbumItemBase | null>(
|
||||||
originalAlbumItem,
|
originalAlbumItem,
|
||||||
);
|
);
|
||||||
@@ -15,63 +23,99 @@ export default function useAlbumDetail(
|
|||||||
originalAlbumItem?.musicList ?? [],
|
originalAlbumItem?.musicList ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAlbumDetail = useCallback(
|
const getAlbumDetail = useCallback(async function () {
|
||||||
async function () {
|
if (
|
||||||
// 加载中:直接退出
|
originalAlbumItem === null ||
|
||||||
if (originalAlbumItem === null ||
|
requestStateRef.current === RequestStateCode.FINISHED ||
|
||||||
requestState === RequestStateCode.FINISHED ||
|
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
|
||||||
requestState === RequestStateCode.PENDING_REST_PAGE) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
try {
|
try {
|
||||||
if (currentPageRef.current === 1) {
|
const currentPage = currentPageRef.current;
|
||||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
const nextState =
|
||||||
} else {
|
currentPage === 1
|
||||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
? RequestStateCode.PENDING_FIRST_PAGE
|
||||||
}
|
: RequestStateCode.PENDING_REST_PAGE;
|
||||||
|
requestStateRef.current = nextState;
|
||||||
|
setRequestState(nextState);
|
||||||
|
|
||||||
const result = await PluginManager.getByMedia(
|
const result = await PluginManager.getByMedia(
|
||||||
originalAlbumItem,
|
originalAlbumItem,
|
||||||
)?.methods?.getAlbumInfo?.(
|
)?.methods?.getAlbumInfo?.(originalAlbumItem, currentPage);
|
||||||
originalAlbumItem,
|
|
||||||
currentPageRef.current,
|
|
||||||
);
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
if (result?.albumItem) {
|
if (result.albumItem) {
|
||||||
setAlbumItem(prev => ({
|
setAlbumItem(prev => ({
|
||||||
...(prev ?? {}),
|
...(prev ?? {}),
|
||||||
...(result.albumItem as IAlbum.IAlbumItemBase),
|
...(result.albumItem as IAlbum.IAlbumItemBase),
|
||||||
platform: originalAlbumItem.platform,
|
platform: originalAlbumItem.platform,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (result?.musicList) {
|
if (result.musicList) {
|
||||||
setMusicList(prev => {
|
setMusicList(prev => {
|
||||||
if (currentPageRef.current === 1) {
|
const nextMusicList =
|
||||||
return result?.musicList ?? prev;
|
currentPage === 1
|
||||||
} else {
|
? result.musicList ?? prev
|
||||||
return [...prev, ...(result.musicList ?? [])];
|
: [...prev, ...(result.musicList ?? [])];
|
||||||
}
|
musicListRef.current = nextMusicList;
|
||||||
|
return nextMusicList;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (result.isEnd) {
|
const finished =
|
||||||
setRequestState(RequestStateCode.FINISHED);
|
result.isEnd === false
|
||||||
} else {
|
? RequestStateCode.PARTLY_DONE
|
||||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
: RequestStateCode.FINISHED;
|
||||||
}
|
requestStateRef.current = finished;
|
||||||
|
setRequestState(finished);
|
||||||
currentPageRef.current += 1;
|
currentPageRef.current += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
requestStateRef.current = RequestStateCode.ERROR;
|
||||||
setRequestState(RequestStateCode.ERROR);
|
setRequestState(RequestStateCode.ERROR);
|
||||||
|
} finally {
|
||||||
|
pendingLoadRef.current = null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
[requestState],
|
});
|
||||||
);
|
}, [originalAlbumItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAlbumDetail();
|
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() {
|
export default function AlbumDetail() {
|
||||||
const { albumItem: originalAlbumItem } = useParams<"album-detail">();
|
const { albumItem: originalAlbumItem } = useParams<"album-detail">();
|
||||||
const [requestStateCode, albumItem, musicList, getAlbumDetail] =
|
const [
|
||||||
|
requestStateCode,
|
||||||
|
albumItem,
|
||||||
|
musicList,
|
||||||
|
getAlbumDetail,
|
||||||
|
resolveMusicListBeforeAdd,
|
||||||
|
] =
|
||||||
useAlbumDetail(originalAlbumItem);
|
useAlbumDetail(originalAlbumItem);
|
||||||
const { t } = useI18N();
|
const { t } = useI18N();
|
||||||
|
|
||||||
@@ -18,6 +24,7 @@ export default function AlbumDetail() {
|
|||||||
onRetry={getAlbumDetail}
|
onRetry={getAlbumDetail}
|
||||||
onLoadMore={getAlbumDetail}
|
onLoadMore={getAlbumDetail}
|
||||||
musicList={musicList}
|
musicList={musicList}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { RequestStateCode } from "@/constants/commonConst";
|
import { RequestStateCode } from "@/constants/commonConst";
|
||||||
import PluginManager from "@/core/pluginManager";
|
import PluginManager from "@/core/pluginManager";
|
||||||
|
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export default function usePluginSheetMusicList(
|
export default function usePluginSheetMusicList(
|
||||||
originalSheetItem: IMusic.IMusicSheetItem | null,
|
originalSheetItem: IMusic.IMusicSheetItem | null,
|
||||||
) {
|
) {
|
||||||
const currentPageRef = useRef(1);
|
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>(
|
const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>(
|
||||||
originalSheetItem,
|
originalSheetItem,
|
||||||
);
|
);
|
||||||
@@ -15,62 +23,100 @@ export default function usePluginSheetMusicList(
|
|||||||
originalSheetItem?.musicList ?? [],
|
originalSheetItem?.musicList ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSheetDetail = useCallback(
|
const getSheetDetail = useCallback(async function () {
|
||||||
async function () {
|
if (
|
||||||
// 加载中:直接退出
|
originalSheetItem === null ||
|
||||||
if (originalSheetItem === null ||
|
requestStateRef.current === RequestStateCode.FINISHED ||
|
||||||
requestState === RequestStateCode.FINISHED ||
|
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
|
||||||
requestState === RequestStateCode.PENDING_REST_PAGE) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
try {
|
try {
|
||||||
if (currentPageRef.current === 1) {
|
const currentPage = currentPageRef.current;
|
||||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
const nextState =
|
||||||
} else {
|
currentPage === 1
|
||||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
? RequestStateCode.PENDING_FIRST_PAGE
|
||||||
}
|
: RequestStateCode.PENDING_REST_PAGE;
|
||||||
|
requestStateRef.current = nextState;
|
||||||
|
setRequestState(nextState);
|
||||||
|
|
||||||
const result = await PluginManager.getByMedia(
|
const result = await PluginManager.getByMedia(
|
||||||
originalSheetItem as any,
|
originalSheetItem as any,
|
||||||
)?.methods?.getMusicSheetInfo?.(
|
)?.methods?.getMusicSheetInfo?.(originalSheetItem, currentPage);
|
||||||
originalSheetItem,
|
|
||||||
currentPageRef.current,
|
|
||||||
);
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
if (result?.sheetItem) {
|
if (result.sheetItem) {
|
||||||
setSheetItem(prev => ({
|
setSheetItem(prev => ({
|
||||||
...(prev ?? {}),
|
...(prev ?? {}),
|
||||||
...(result.sheetItem as IMusic.IMusicSheetItem),
|
...(result.sheetItem as IMusic.IMusicSheetItem),
|
||||||
platform: originalSheetItem.platform,
|
platform: originalSheetItem.platform,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (result?.musicList) {
|
if (result.musicList) {
|
||||||
setMusicList(prev => {
|
setMusicList(prev => {
|
||||||
if (currentPageRef.current === 1) {
|
const nextMusicList =
|
||||||
return result?.musicList ?? prev;
|
currentPage === 1
|
||||||
} else {
|
? result.musicList ?? prev
|
||||||
return [...prev, ...(result.musicList ?? [])];
|
: [...prev, ...(result.musicList ?? [])];
|
||||||
}
|
musicListRef.current = nextMusicList;
|
||||||
|
return nextMusicList;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (result.isEnd) {
|
const finished =
|
||||||
setRequestState(RequestStateCode.FINISHED);
|
result.isEnd === false
|
||||||
} else {
|
? RequestStateCode.PARTLY_DONE
|
||||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
: RequestStateCode.FINISHED;
|
||||||
}
|
requestStateRef.current = finished;
|
||||||
|
setRequestState(finished);
|
||||||
currentPageRef.current += 1;
|
currentPageRef.current += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
requestStateRef.current = RequestStateCode.ERROR;
|
||||||
setRequestState(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(() => {
|
useEffect(() => {
|
||||||
getSheetDetail();
|
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() {
|
export default function PluginSheetDetail() {
|
||||||
const { sheetInfo } = useParams<"plugin-sheet-detail">();
|
const { sheetInfo } = useParams<"plugin-sheet-detail">();
|
||||||
|
|
||||||
const [requestState, sheetItem, musicList, getSheetDetail] =
|
const [
|
||||||
|
requestState,
|
||||||
|
sheetItem,
|
||||||
|
musicList,
|
||||||
|
getSheetDetail,
|
||||||
|
resolveMusicListBeforeAdd,
|
||||||
|
] =
|
||||||
usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem);
|
usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem);
|
||||||
return (
|
return (
|
||||||
<MusicSheetPage
|
<MusicSheetPage
|
||||||
@@ -18,6 +24,7 @@ export default function PluginSheetDetail() {
|
|||||||
state={requestState}
|
state={requestState}
|
||||||
onRetry={getSheetDetail}
|
onRetry={getSheetDetail}
|
||||||
onLoadMore={getSheetDetail}
|
onLoadMore={getSheetDetail}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RequestStateCode } from "@/constants/commonConst";
|
import { RequestStateCode } from "@/constants/commonConst";
|
||||||
import PluginManager from "@/core/pluginManager";
|
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(
|
export default function useTopListDetail(
|
||||||
topListItem: IMusic.IMusicSheetItemBase | null,
|
topListItem: IMusic.IMusicSheetItemBase | null,
|
||||||
@@ -12,64 +13,107 @@ export default function useTopListDetail(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pageRef = useRef(1);
|
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);
|
const [requestState, setRequestState] = useState(RequestStateCode.IDLE);
|
||||||
|
|
||||||
async function loadMore() {
|
const loadMore = useCallback(async () => {
|
||||||
if (!topListItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (
|
if (
|
||||||
requestState === RequestStateCode.PENDING_FIRST_PAGE ||
|
!topListItem ||
|
||||||
requestState === RequestStateCode.PENDING_REST_PAGE ||
|
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
|
||||||
requestState === RequestStateCode.FINISHED
|
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE ||
|
||||||
|
requestStateRef.current === RequestStateCode.FINISHED
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageRef.current === 1) {
|
|
||||||
setRequestState(RequestStateCode.PENDING_FIRST_PAGE);
|
const task = (async () => {
|
||||||
} else {
|
try {
|
||||||
setRequestState(RequestStateCode.PENDING_REST_PAGE);
|
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(
|
const result = await PluginManager.getByHash(
|
||||||
pluginHash,
|
pluginHash,
|
||||||
)?.methods?.getTopListDetail(topListItem, pageRef.current);
|
)?.methods?.getTopListDetail(topListItem, currentPage);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
const currentPage = pageRef.current;
|
|
||||||
setMergedTopListItem(
|
setMergedTopListItem(prev => {
|
||||||
prev =>
|
const nextMusicList =
|
||||||
({
|
|
||||||
...prev,
|
|
||||||
...result.topListItem,
|
|
||||||
musicList:
|
|
||||||
currentPage === 1
|
currentPage === 1
|
||||||
? result.musicList ?? []
|
? result.musicList ?? []
|
||||||
: [
|
: [
|
||||||
...(prev?.musicList ?? []),
|
...(prev?.musicList ?? []),
|
||||||
...(result.musicList ?? []),
|
...(result.musicList ?? []),
|
||||||
],
|
];
|
||||||
} as IMusic.IMusicSheetItem),
|
musicListRef.current = nextMusicList;
|
||||||
);
|
return {
|
||||||
|
...prev,
|
||||||
|
...result.topListItem,
|
||||||
|
musicList: nextMusicList,
|
||||||
|
} as IMusic.IMusicSheetItem;
|
||||||
|
});
|
||||||
|
|
||||||
if (result.isEnd === false) {
|
const finished =
|
||||||
setRequestState(RequestStateCode.PARTLY_DONE);
|
result.isEnd === false
|
||||||
} else {
|
? RequestStateCode.PARTLY_DONE
|
||||||
setRequestState(RequestStateCode.FINISHED);
|
: RequestStateCode.FINISHED;
|
||||||
}
|
requestStateRef.current = finished;
|
||||||
pageRef.current++;
|
setRequestState(finished);
|
||||||
|
pageRef.current += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
requestStateRef.current = RequestStateCode.ERROR;
|
||||||
setRequestState(RequestStateCode.ERROR);
|
setRequestState(RequestStateCode.ERROR);
|
||||||
|
} finally {
|
||||||
|
pendingLoadRef.current = null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (topListItem === null) {
|
if (topListItem === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadMore();
|
loadMore();
|
||||||
}, []);
|
}, [loadMore, topListItem]);
|
||||||
return [mergedTopListItem, requestState, loadMore] as const;
|
|
||||||
|
return [
|
||||||
|
mergedTopListItem,
|
||||||
|
requestState,
|
||||||
|
loadMore,
|
||||||
|
resolveMusicListBeforeAdd,
|
||||||
|
] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import useTopListDetail from "./hooks/useTopListDetail";
|
|||||||
|
|
||||||
export default function TopListDetail() {
|
export default function TopListDetail() {
|
||||||
const { pluginHash, topList } = useParams<"top-list-detail">();
|
const { pluginHash, topList } = useParams<"top-list-detail">();
|
||||||
const [topListDetail, state, loadMore] = useTopListDetail(
|
const [topListDetail, state, loadMore, resolveMusicListBeforeAdd] = useTopListDetail(
|
||||||
topList,
|
topList,
|
||||||
pluginHash,
|
pluginHash,
|
||||||
);
|
);
|
||||||
@@ -19,6 +19,7 @@ export default function TopListDetail() {
|
|||||||
state={state}
|
state={state}
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
onRetry={loadMore}
|
onRetry={loadMore}
|
||||||
|
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import Config from "@/core/appConfig";
|
import Config from "@/core/appConfig";
|
||||||
|
import { ImgAsset } from "@/constants/assetsConst";
|
||||||
import pathConst from "@/constants/pathConst";
|
import pathConst from "@/constants/pathConst";
|
||||||
import musicHistory from "@/core/musicHistory";
|
import musicHistory from "@/core/musicHistory";
|
||||||
import PluginManager from "@/core/pluginManager";
|
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 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 { 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 { musicIsPaused } from "@/utils/trackUtils";
|
||||||
import PersistStatus from "@/utils/persistStatus";
|
import PersistStatus from "@/utils/persistStatus";
|
||||||
|
import { applyTrackPlayerOptions } from "@/entry/bootstrap/trackPlayerOptions";
|
||||||
|
|
||||||
(
|
(
|
||||||
globalThis as typeof globalThis & {
|
globalThis as typeof globalThis & {
|
||||||
@@ -17,6 +30,131 @@ import PersistStatus from "@/utils/persistStatus";
|
|||||||
|
|
||||||
let resumeState: State | null;
|
let resumeState: State | null;
|
||||||
let serviceReadyPromise: Promise<void> | null = 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) {
|
async function ensureServiceReady(from: string) {
|
||||||
if (serviceReadyPromise) {
|
if (serviceReadyPromise) {
|
||||||
@@ -40,10 +178,28 @@ async function ensureServiceReady(from: string) {
|
|||||||
await Config.setup();
|
await Config.setup();
|
||||||
await musicHistory.setup();
|
await musicHistory.setup();
|
||||||
await PluginManager.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();
|
await TrackPlayer.setupTrackPlayer();
|
||||||
|
|
||||||
trace(`[TrackPlayer service] ready from ${from}`);
|
trace(`[TrackPlayer service] ready from ${from}`);
|
||||||
forceTrace("[PlaybackService] ready", { from });
|
forceTrace("[PlaybackService] ready", { from });
|
||||||
|
startPlaybackHealthCheck();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
serviceReadyPromise = null;
|
serviceReadyPromise = null;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -97,7 +253,13 @@ module.exports = async function () {
|
|||||||
runSafely("RemotePause", () => TrackPlayer.pause()),
|
runSafely("RemotePause", () => TrackPlayer.pause()),
|
||||||
);
|
);
|
||||||
RNTrackPlayer.addEventListener(Event.RemotePrevious, () =>
|
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, () =>
|
RNTrackPlayer.addEventListener(Event.RemoteNext, () =>
|
||||||
runSafely("RemoteNext", () => TrackPlayer.skipToNext()),
|
runSafely("RemoteNext", () => TrackPlayer.skipToNext()),
|
||||||
@@ -170,6 +332,43 @@ module.exports = async function () {
|
|||||||
|
|
||||||
RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => {
|
RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => {
|
||||||
PersistStatus.set("music.progress", evt.position);
|
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 () => {
|
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.latestVersion": string; // 当前已是最新版本
|
||||||
|
"checkUpdate.error.checkFailed": string; // 检查更新失败
|
||||||
|
|
||||||
// 首页相关
|
// 首页相关
|
||||||
"home.recommendSheet": string; // 推荐歌单
|
"home.recommendSheet": string; // 推荐歌单
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ describe("checkUpdate", () => {
|
|||||||
|
|
||||||
const result = await checkUpdate();
|
const result = await checkUpdate();
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
expect(result).toEqual({
|
||||||
|
needUpdate: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||||
expect(axios.get).toHaveBeenCalledWith(
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
"http://10.0.0.2:18080/app/version.json",
|
"http://10.0.0.2:18080/app/version.json",
|
||||||
@@ -99,4 +102,58 @@ describe("checkUpdate", () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
expect(axios.get).not.toHaveBeenCalled();
|
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 {
|
interface IUpdateInfo {
|
||||||
needUpdate: boolean;
|
needUpdate: boolean;
|
||||||
|
error?: boolean;
|
||||||
data: {
|
data: {
|
||||||
version: string;
|
version: string;
|
||||||
changeLog: string[];
|
changeLog: string[];
|
||||||
@@ -74,13 +75,14 @@ export default async function checkUpdate(): Promise<IUpdateInfo | undefined> {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rawInfo = (await axios.get(updateUrl)).data;
|
const rawInfo = (await axios.get(updateUrl)).data;
|
||||||
if (compare(rawInfo.version, currentVersion, ">")) {
|
|
||||||
return {
|
return {
|
||||||
needUpdate: true,
|
needUpdate: compare(rawInfo.version, currentVersion, ">"),
|
||||||
data: rawInfo,
|
data: rawInfo,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} catch {
|
} 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 log = logger.createLogger(config);
|
||||||
const traceLogger = logger.createLogger(traceConfig);
|
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(
|
export function trace(
|
||||||
desc: string,
|
desc: string,
|
||||||
@@ -97,7 +118,6 @@ export async function clearLog() {
|
|||||||
export async function getErrorLogContent() {
|
export async function getErrorLogContent() {
|
||||||
try {
|
try {
|
||||||
const files = await readDir(pathConst.logPath);
|
const files = await readDir(pathConst.logPath);
|
||||||
console.log(files);
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
// 两天的错误日志
|
// 两天的错误日志
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
@@ -120,6 +140,9 @@ export async function getErrorLogContent() {
|
|||||||
}-${yesterday.getFullYear()}.log`,
|
}-${yesterday.getFullYear()}.log`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const traceLog = files.find(
|
||||||
|
_ => _.isFile() && _.path.endsWith("trace-log.log"),
|
||||||
|
);
|
||||||
let logContent = "";
|
let logContent = "";
|
||||||
if (todayLog) {
|
if (todayLog) {
|
||||||
logContent += await readFile(todayLog.path, "utf8");
|
logContent += await readFile(todayLog.path, "utf8");
|
||||||
@@ -127,6 +150,23 @@ export async function getErrorLogContent() {
|
|||||||
if (yesterdayLog) {
|
if (yesterdayLog) {
|
||||||
logContent += await readFile(yesterdayLog.path, "utf8");
|
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;
|
return logContent;
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
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(
|
export function devLog(
|
||||||
method: "log" | "error" | "warn" | "info",
|
method: "log" | "error" | "warn" | "info",
|
||||||
...args: any[]
|
...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;
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@ MUSIC_SERVER_ADMIN_USERNAME=admin
|
|||||||
MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex
|
MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex
|
||||||
MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret
|
MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret
|
||||||
MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600
|
MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600
|
||||||
|
MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS=3600
|
||||||
MUSICFREE_VERSION_JSON=/app/release/version.json
|
MUSICFREE_VERSION_JSON=/app/release/version.json
|
||||||
MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk
|
MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
param(
|
param(
|
||||||
[string]$HostName = "192.168.5.43",
|
[string]$HostName = "192.168.5.11",
|
||||||
[int]$Port = 222,
|
[int]$Port = 222,
|
||||||
[string]$User = "xiaoming",
|
[string]$User = "xiaoming",
|
||||||
[string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server",
|
[string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh"
|
description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh"
|
||||||
)
|
)
|
||||||
parser.add_argument("--host", default="192.168.5.43")
|
parser.add_argument("--host", default="192.168.5.11")
|
||||||
parser.add_argument("--port", type=int, default=222)
|
parser.add_argument("--port", type=int, default=222)
|
||||||
parser.add_argument("--user", default="xiaoming")
|
parser.add_argument("--user", default="xiaoming")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ def _build_stream_url(*, token: str, resolved: dict) -> str:
|
|||||||
return f"/mf/v1/media/stream/{token}"
|
return f"/mf/v1/media/stream/{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_stream_source(*, song_id: int, locator: str, quality: str, settings) -> dict:
|
||||||
|
resolver = MediaResolver(db_path=settings.catalog_db_path)
|
||||||
|
if locator:
|
||||||
|
try:
|
||||||
|
return resolver.resolve_by_locator(song_id=song_id, locator=locator)
|
||||||
|
except LookupError:
|
||||||
|
pass
|
||||||
|
return resolver.resolve(song_id=song_id, quality=quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_origin_stream_reachable(public_url: str) -> bool:
|
||||||
|
return _is_cache_url_reachable(public_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_viable_origin_source(*, song_id: int, quality: str, settings, preferred_locator: str = "") -> dict:
|
||||||
|
resolver = MediaResolver(db_path=settings.catalog_db_path)
|
||||||
|
candidates = resolver.resolve_candidates(song_id=song_id, quality=quality)
|
||||||
|
if not candidates:
|
||||||
|
raise LookupError("no playable source found")
|
||||||
|
|
||||||
|
def _sort_key(item: dict) -> tuple[int, int, str]:
|
||||||
|
locator = str(item.get("locator") or "")
|
||||||
|
return (0 if preferred_locator and locator == preferred_locator else 1, 0, locator)
|
||||||
|
|
||||||
|
for candidate in sorted(candidates, key=_sort_key):
|
||||||
|
if candidate.get("backend_type") == "local_fs":
|
||||||
|
return candidate
|
||||||
|
public_url = str(candidate.get("public_url") or "")
|
||||||
|
if public_url and _is_origin_stream_reachable(public_url):
|
||||||
|
return candidate
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/media/resolve")
|
@router.post("/media/resolve")
|
||||||
def resolve_media(payload: dict) -> dict:
|
def resolve_media(payload: dict) -> dict:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -146,6 +179,8 @@ def resolve_media(payload: dict) -> dict:
|
|||||||
secret=settings.access_token,
|
secret=settings.access_token,
|
||||||
song_id=song_id,
|
song_id=song_id,
|
||||||
locator=token_locator,
|
locator=token_locator,
|
||||||
|
quality=quality,
|
||||||
|
ttl_seconds=settings.stream_token_ttl_seconds,
|
||||||
)
|
)
|
||||||
selected_source = cached_source or fallback_source or {}
|
selected_source = cached_source or fallback_source or {}
|
||||||
selected_size = None
|
selected_size = None
|
||||||
@@ -175,6 +210,7 @@ def stream_media(token: str, request: Request, ext: str | None = None):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
song_id = int(parsed["song_id"])
|
song_id = int(parsed["song_id"])
|
||||||
|
quality = str(parsed.get("quality") or "standard")
|
||||||
cache_service = _cache_service(settings)
|
cache_service = _cache_service(settings)
|
||||||
|
|
||||||
cached_source = cache_service.resolve_cached_source(song_id=song_id)
|
cached_source = cache_service.resolve_cached_source(song_id=song_id)
|
||||||
@@ -183,9 +219,11 @@ def stream_media(token: str, request: Request, ext: str | None = None):
|
|||||||
return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
|
return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
|
resolved = _resolve_stream_source(
|
||||||
song_id=song_id,
|
song_id=song_id,
|
||||||
locator=str(parsed["locator"]),
|
locator=str(parsed.get("locator") or ""),
|
||||||
|
quality=quality,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
except LookupError as exc:
|
except LookupError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
@@ -237,6 +275,17 @@ def stream_media(token: str, request: Request, ext: str | None = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
public_url = resolved.get("public_url")
|
public_url = resolved.get("public_url")
|
||||||
|
if public_url and not _is_origin_stream_reachable(str(public_url)):
|
||||||
|
try:
|
||||||
|
resolved = _pick_viable_origin_source(
|
||||||
|
song_id=song_id,
|
||||||
|
quality=quality,
|
||||||
|
settings=settings,
|
||||||
|
preferred_locator=str(resolved.get("locator") or ""),
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
pass
|
||||||
|
public_url = resolved.get("public_url")
|
||||||
if not public_url:
|
if not public_url:
|
||||||
raise HTTPException(status_code=404, detail="public stream url not found")
|
raise HTTPException(status_code=404, detail="public stream url not found")
|
||||||
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
||||||
|
|||||||
@@ -7,21 +7,24 @@ class MediaResolver:
|
|||||||
def __init__(self, db_path: str) -> None:
|
def __init__(self, db_path: str) -> None:
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
|
|
||||||
def resolve(self, song_id: int, quality: str) -> dict:
|
def resolve_candidates(self, song_id: int, quality: str) -> list[dict]:
|
||||||
with closing(connect_sqlite(self._db_path)) as conn:
|
with closing(connect_sqlite(self._db_path)) as conn:
|
||||||
row = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
|
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, is_primary
|
||||||
from catalog_track_files
|
from catalog_track_files
|
||||||
where song_id = ? and status = 'active'
|
where song_id = ? and status = 'active'
|
||||||
order by case when quality_label = ? then 0 else 1 end, is_primary desc
|
order by case when quality_label = ? then 0 else 1 end, is_primary desc, locator asc
|
||||||
limit 1
|
|
||||||
""",
|
""",
|
||||||
(song_id, quality),
|
(song_id, quality),
|
||||||
).fetchone()
|
).fetchall()
|
||||||
if row is None:
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def resolve(self, song_id: int, quality: str) -> dict:
|
||||||
|
rows = self.resolve_candidates(song_id=song_id, quality=quality)
|
||||||
|
if not rows:
|
||||||
raise LookupError("no playable source found")
|
raise LookupError("no playable source found")
|
||||||
return dict(row)
|
return rows[0]
|
||||||
|
|
||||||
def resolve_by_locator(self, song_id: int, locator: str) -> dict:
|
def resolve_by_locator(self, song_id: int, locator: str) -> dict:
|
||||||
with closing(connect_sqlite(self._db_path)) as conn:
|
with closing(connect_sqlite(self._db_path)) as conn:
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ def _sign_payload(secret: str, payload_json: str) -> str:
|
|||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def create_stream_token(secret: str, song_id: int, locator: str, ttl_seconds: int = 300) -> str:
|
def create_stream_token(
|
||||||
|
secret: str,
|
||||||
|
song_id: int,
|
||||||
|
locator: str,
|
||||||
|
*,
|
||||||
|
quality: str | None = None,
|
||||||
|
ttl_seconds: int = 3600,
|
||||||
|
) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"song_id": int(song_id),
|
"song_id": int(song_id),
|
||||||
"locator": str(locator),
|
"locator": str(locator),
|
||||||
|
"quality": str(quality or "standard"),
|
||||||
"expires_at": int(time.time()) + int(ttl_seconds),
|
"expires_at": int(time.time()) + int(ttl_seconds),
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||||
@@ -40,15 +48,15 @@ def parse_stream_token(secret: str, token: str) -> dict:
|
|||||||
raise ValueError("invalid stream token")
|
raise ValueError("invalid stream token")
|
||||||
|
|
||||||
song_id = int(payload["song_id"])
|
song_id = int(payload["song_id"])
|
||||||
locator = str(payload["locator"])
|
locator = str(payload.get("locator") or "")
|
||||||
|
quality = str(payload.get("quality") or "standard")
|
||||||
expires_at = int(payload["expires_at"])
|
expires_at = int(payload["expires_at"])
|
||||||
if not locator:
|
|
||||||
raise ValueError("invalid stream token")
|
|
||||||
if expires_at < int(time.time()):
|
if expires_at < int(time.time()):
|
||||||
raise ValueError("stream token expired")
|
raise ValueError("stream token expired")
|
||||||
return {
|
return {
|
||||||
"song_id": song_id,
|
"song_id": song_id,
|
||||||
"locator": locator,
|
"locator": locator,
|
||||||
|
"quality": quality,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
}
|
}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Settings:
|
|||||||
admin_password_hash: str
|
admin_password_hash: str
|
||||||
secret_encryption_key: str
|
secret_encryption_key: str
|
||||||
cache_reconcile_interval_seconds: int
|
cache_reconcile_interval_seconds: int
|
||||||
|
stream_token_ttl_seconds: int
|
||||||
musicfree_version_json_path: str
|
musicfree_version_json_path: str
|
||||||
musicfree_apk_path: str
|
musicfree_apk_path: str
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ def get_settings() -> Settings:
|
|||||||
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
|
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
|
||||||
600,
|
600,
|
||||||
),
|
),
|
||||||
|
stream_token_ttl_seconds=_env_int(
|
||||||
|
"MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS",
|
||||||
|
3600,
|
||||||
|
),
|
||||||
musicfree_version_json_path=os.getenv(
|
musicfree_version_json_path=os.getenv(
|
||||||
"MUSICFREE_VERSION_JSON",
|
"MUSICFREE_VERSION_JSON",
|
||||||
str(musicfree_release_dir / "version.json"),
|
str(musicfree_release_dir / "version.json"),
|
||||||
|
|||||||
@@ -84,4 +84,3 @@ class AppUpdateRouteTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -156,6 +157,42 @@ class MfMediaRouteTests(unittest.TestCase):
|
|||||||
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
|
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
|
||||||
self.assertTrue(payload["stream"]["url"].endswith(".flac"))
|
self.assertTrue(payload["stream"]["url"].endswith(".flac"))
|
||||||
|
|
||||||
|
def test_media_resolve_issues_longer_lived_stream_token_for_background_queueing(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "catalog_read.db"
|
||||||
|
player_db_path = Path(tmpdir) / "player.db"
|
||||||
|
self._prepare_catalog_db(db_path)
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"CATALOG_DB_PATH": str(db_path),
|
||||||
|
"PLAYER_DB_PATH": str(player_db_path),
|
||||||
|
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
client = TestClient(create_app())
|
||||||
|
resolve_response = client.post(
|
||||||
|
"/mf/v1/media/resolve",
|
||||||
|
headers=auth_headers(player_db_path),
|
||||||
|
json={"song_id": "catalogsync:song:3476", "quality": "super"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, resolve_response.status_code)
|
||||||
|
stream_url = resolve_response.json()["stream"]["url"]
|
||||||
|
token = stream_url.rsplit("/", 1)[-1].split(".", 1)[0]
|
||||||
|
|
||||||
|
from music_server.services.stream_tokens import parse_stream_token
|
||||||
|
|
||||||
|
parsed = parse_stream_token(secret="dev-token", token=token)
|
||||||
|
remaining = parsed["expires_at"] - int(time.time())
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
remaining,
|
||||||
|
1800,
|
||||||
|
"stream token ttl should be long enough for background playback queueing",
|
||||||
|
)
|
||||||
|
|
||||||
def test_media_stream_redirects_to_public_url(self):
|
def test_media_stream_redirects_to_public_url(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "catalog_read.db"
|
db_path = Path(tmpdir) / "catalog_read.db"
|
||||||
@@ -353,6 +390,170 @@ class MfMediaRouteTests(unittest.TestCase):
|
|||||||
stream_response.headers.get("location"),
|
stream_response.headers.get("location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_media_stream_falls_back_to_current_active_source_when_token_locator_is_stale(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "catalog_read.db"
|
||||||
|
player_db_path = Path(tmpdir) / "player.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
create table catalog_track_files (
|
||||||
|
song_id integer not null,
|
||||||
|
quality_label text not null,
|
||||||
|
ext text not null,
|
||||||
|
file_size_bytes integer not null,
|
||||||
|
backend_type text not null,
|
||||||
|
backend_name text not null,
|
||||||
|
locator text not null,
|
||||||
|
public_url text,
|
||||||
|
status text not null,
|
||||||
|
is_primary integer not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
insert into catalog_track_files (
|
||||||
|
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name,
|
||||||
|
locator, public_url, status, is_primary
|
||||||
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
3476,
|
||||||
|
"super",
|
||||||
|
"flac",
|
||||||
|
42345678,
|
||||||
|
"object_storage",
|
||||||
|
"main-s3",
|
||||||
|
"music/netease/new.flac",
|
||||||
|
"https://cdn.example/new.flac",
|
||||||
|
"active",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"CATALOG_DB_PATH": str(db_path),
|
||||||
|
"PLAYER_DB_PATH": str(player_db_path),
|
||||||
|
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
client = TestClient(create_app())
|
||||||
|
from music_server.services.stream_tokens import create_stream_token
|
||||||
|
|
||||||
|
stale_token = create_stream_token(
|
||||||
|
secret="dev-token",
|
||||||
|
song_id=3476,
|
||||||
|
locator="music/netease/old.flac",
|
||||||
|
ttl_seconds=3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_response = client.get(
|
||||||
|
f"/mf/v1/media/stream/{stale_token}.flac",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(307, stream_response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://cdn.example/new.flac",
|
||||||
|
stream_response.headers.get("location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_media_stream_falls_back_to_next_origin_source_when_selected_public_url_is_unreachable(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "catalog_read.db"
|
||||||
|
player_db_path = Path(tmpdir) / "player.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
create table catalog_track_files (
|
||||||
|
song_id integer not null,
|
||||||
|
quality_label text not null,
|
||||||
|
ext text not null,
|
||||||
|
file_size_bytes integer not null,
|
||||||
|
backend_type text not null,
|
||||||
|
backend_name text not null,
|
||||||
|
locator text not null,
|
||||||
|
public_url text,
|
||||||
|
status text not null,
|
||||||
|
is_primary integer not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
insert into catalog_track_files (
|
||||||
|
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name,
|
||||||
|
locator, public_url, status, is_primary
|
||||||
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
3476,
|
||||||
|
"super",
|
||||||
|
"flac",
|
||||||
|
42345678,
|
||||||
|
"object_storage",
|
||||||
|
"main-s3",
|
||||||
|
"music/netease/first.flac",
|
||||||
|
"https://cdn.example/first.flac",
|
||||||
|
"active",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
3476,
|
||||||
|
"super",
|
||||||
|
"flac",
|
||||||
|
42345678,
|
||||||
|
"object_storage",
|
||||||
|
"backup-s3",
|
||||||
|
"music/netease/second.flac",
|
||||||
|
"https://cdn.example/second.flac",
|
||||||
|
"active",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"CATALOG_DB_PATH": str(db_path),
|
||||||
|
"PLAYER_DB_PATH": str(player_db_path),
|
||||||
|
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
client = TestClient(create_app())
|
||||||
|
resolve_response = client.post(
|
||||||
|
"/mf/v1/media/resolve",
|
||||||
|
headers=auth_headers(player_db_path),
|
||||||
|
json={"song_id": "catalogsync:song:3476", "quality": "super"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, resolve_response.status_code)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"music_server.routes.mf_media._is_origin_stream_reachable",
|
||||||
|
side_effect=lambda url: url.endswith("second.flac"),
|
||||||
|
):
|
||||||
|
stream_response = client.get(
|
||||||
|
resolve_response.json()["stream"]["url"],
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(307, stream_response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://cdn.example/second.flac",
|
||||||
|
stream_response.headers.get("location"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
|
def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "catalog_read.db"
|
db_path = Path(tmpdir) / "catalog_read.db"
|
||||||
|
|||||||
Reference in New Issue
Block a user