react-native-nitro-player 0.7.1-alpha.2 → 0.7.1-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +8 -48
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +78 -18
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +15 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +25 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +94 -23
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +49 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +167 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +10 -301
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +200 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +72 -11
- package/ios/core/TrackPlayerQueueBuild.swift +16 -2
- package/package.json +1 -1
package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
@file:Suppress("ktlint:standard:max-line-length")
|
|
2
|
+
|
|
3
|
+
package com.margelo.nitro.nitroplayer.media
|
|
4
|
+
|
|
5
|
+
import android.app.Notification
|
|
6
|
+
import android.app.NotificationChannel
|
|
7
|
+
import android.app.NotificationManager
|
|
8
|
+
import android.app.PendingIntent
|
|
9
|
+
import android.content.Context
|
|
10
|
+
import android.content.Intent
|
|
11
|
+
import android.graphics.Bitmap
|
|
12
|
+
import android.graphics.BitmapFactory
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import android.os.Bundle
|
|
15
|
+
import android.util.LruCache
|
|
16
|
+
import android.view.KeyEvent
|
|
17
|
+
import androidx.core.app.NotificationCompat
|
|
18
|
+
import androidx.media3.common.util.UnstableApi
|
|
19
|
+
import androidx.media3.session.CommandButton
|
|
20
|
+
import androidx.media3.session.MediaNotification
|
|
21
|
+
import androidx.media3.session.MediaSession
|
|
22
|
+
import com.google.common.collect.ImmutableList
|
|
23
|
+
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
24
|
+
import kotlinx.coroutines.CoroutineScope
|
|
25
|
+
import kotlinx.coroutines.Dispatchers
|
|
26
|
+
import kotlinx.coroutines.SupervisorJob
|
|
27
|
+
import kotlinx.coroutines.launch
|
|
28
|
+
import kotlinx.coroutines.withContext
|
|
29
|
+
import java.net.URL
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Custom notification provider that manually builds a [MediaStyle][androidx.media.app.NotificationCompat.MediaStyle]
|
|
33
|
+
* notification with artwork, title, artist, and transport controls — matching
|
|
34
|
+
* the proven approach from the main branch.
|
|
35
|
+
*/
|
|
36
|
+
@UnstableApi
|
|
37
|
+
class NitroPlayerNotificationProvider(
|
|
38
|
+
private val context: Context,
|
|
39
|
+
) : MediaNotification.Provider {
|
|
40
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
41
|
+
private val notificationManager =
|
|
42
|
+
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
43
|
+
private val artworkCache =
|
|
44
|
+
object : LruCache<String, Bitmap>(20) {
|
|
45
|
+
override fun sizeOf(key: String, value: Bitmap): Int = 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
companion object {
|
|
49
|
+
const val NOTIFICATION_ID = 1001
|
|
50
|
+
private const val CHANNEL_ID = "nitro_player_channel"
|
|
51
|
+
private const val CHANNEL_NAME = "Music Player"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
init {
|
|
55
|
+
createNotificationChannel()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun createNotification(
|
|
59
|
+
mediaSession: MediaSession,
|
|
60
|
+
customLayout: ImmutableList<CommandButton>,
|
|
61
|
+
actionFactory: MediaNotification.ActionFactory,
|
|
62
|
+
onNotificationChangedCallback: MediaNotification.Provider.Callback,
|
|
63
|
+
): MediaNotification {
|
|
64
|
+
val player = mediaSession.player
|
|
65
|
+
val metadata = player.mediaMetadata
|
|
66
|
+
val isPlaying = player.isPlaying
|
|
67
|
+
|
|
68
|
+
val contentIntent =
|
|
69
|
+
PendingIntent.getActivity(
|
|
70
|
+
context,
|
|
71
|
+
0,
|
|
72
|
+
context.packageManager.getLaunchIntentForPackage(context.packageName),
|
|
73
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
val builder =
|
|
77
|
+
NotificationCompat
|
|
78
|
+
.Builder(context, CHANNEL_ID)
|
|
79
|
+
.setContentTitle(metadata.title ?: "Unknown Title")
|
|
80
|
+
.setContentText(metadata.artist ?: "Unknown Artist")
|
|
81
|
+
.setSubText(metadata.albumTitle ?: "")
|
|
82
|
+
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
83
|
+
.setContentIntent(contentIntent)
|
|
84
|
+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
85
|
+
.setOngoing(isPlaying)
|
|
86
|
+
.setShowWhen(false)
|
|
87
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
88
|
+
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
|
89
|
+
|
|
90
|
+
// MediaStyle with session token
|
|
91
|
+
try {
|
|
92
|
+
val compatToken =
|
|
93
|
+
android.support.v4.media.session.MediaSessionCompat.Token
|
|
94
|
+
.fromToken(mediaSession.platformToken)
|
|
95
|
+
builder.setStyle(
|
|
96
|
+
androidx.media.app.NotificationCompat
|
|
97
|
+
.MediaStyle()
|
|
98
|
+
.setMediaSession(compatToken)
|
|
99
|
+
.setShowActionsInCompactView(0, 1, 2),
|
|
100
|
+
)
|
|
101
|
+
} catch (e: Exception) {
|
|
102
|
+
NitroPlayerLogger.log("NotificationProvider") { "Failed to set media session token: ${e.message}" }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Transport actions using media button PendingIntents
|
|
106
|
+
builder.addAction(
|
|
107
|
+
android.R.drawable.ic_media_previous,
|
|
108
|
+
"Previous",
|
|
109
|
+
buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PREVIOUS, 0),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (isPlaying) {
|
|
113
|
+
builder.addAction(
|
|
114
|
+
android.R.drawable.ic_media_pause,
|
|
115
|
+
"Pause",
|
|
116
|
+
buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PAUSE, 1),
|
|
117
|
+
)
|
|
118
|
+
} else {
|
|
119
|
+
builder.addAction(
|
|
120
|
+
android.R.drawable.ic_media_play,
|
|
121
|
+
"Play",
|
|
122
|
+
buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY, 1),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
builder.addAction(
|
|
127
|
+
android.R.drawable.ic_media_next,
|
|
128
|
+
"Next",
|
|
129
|
+
buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_NEXT, 2),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// Load artwork async and update notification
|
|
133
|
+
metadata.artworkUri?.toString()?.let { artworkUrl ->
|
|
134
|
+
val cached = artworkCache.get(artworkUrl)
|
|
135
|
+
if (cached != null) {
|
|
136
|
+
builder.setLargeIcon(cached)
|
|
137
|
+
} else {
|
|
138
|
+
scope.launch {
|
|
139
|
+
val bitmap = loadArtworkBitmap(artworkUrl)
|
|
140
|
+
if (bitmap != null) {
|
|
141
|
+
builder.setLargeIcon(bitmap)
|
|
142
|
+
onNotificationChangedCallback.onNotificationChanged(
|
|
143
|
+
MediaNotification(NOTIFICATION_ID, builder.build()),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return MediaNotification(NOTIFICATION_ID, builder.build())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
override fun handleCustomCommand(
|
|
154
|
+
session: MediaSession,
|
|
155
|
+
action: String,
|
|
156
|
+
extras: Bundle,
|
|
157
|
+
): Boolean = false
|
|
158
|
+
|
|
159
|
+
private fun buildMediaButtonIntent(keyCode: Int, requestCode: Int): PendingIntent {
|
|
160
|
+
val intent =
|
|
161
|
+
Intent(Intent.ACTION_MEDIA_BUTTON).apply {
|
|
162
|
+
putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
|
|
163
|
+
setPackage(context.packageName)
|
|
164
|
+
}
|
|
165
|
+
return PendingIntent.getBroadcast(
|
|
166
|
+
context,
|
|
167
|
+
requestCode,
|
|
168
|
+
intent,
|
|
169
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun createNotificationChannel() {
|
|
174
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
175
|
+
val channel =
|
|
176
|
+
NotificationChannel(
|
|
177
|
+
CHANNEL_ID,
|
|
178
|
+
CHANNEL_NAME,
|
|
179
|
+
NotificationManager.IMPORTANCE_LOW,
|
|
180
|
+
).apply {
|
|
181
|
+
description = "Media playback controls"
|
|
182
|
+
setShowBadge(false)
|
|
183
|
+
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
184
|
+
}
|
|
185
|
+
notificationManager.createNotificationChannel(channel)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private suspend fun loadArtworkBitmap(artworkUrl: String): Bitmap? {
|
|
190
|
+
artworkCache.get(artworkUrl)?.let { return it }
|
|
191
|
+
return try {
|
|
192
|
+
withContext(Dispatchers.IO) {
|
|
193
|
+
val url = URL(artworkUrl)
|
|
194
|
+
BitmapFactory.decodeStream(url.openConnection().getInputStream())
|
|
195
|
+
}?.also { artworkCache.put(artworkUrl, it) }
|
|
196
|
+
} catch (_: Exception) {
|
|
197
|
+
null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -1,28 +1,86 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.media
|
|
2
2
|
|
|
3
3
|
import android.content.Intent
|
|
4
|
+
import android.os.Binder
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.IBinder
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import androidx.media3.common.util.UnstableApi
|
|
9
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
4
10
|
import androidx.media3.session.MediaSession
|
|
5
11
|
import androidx.media3.session.MediaSessionService
|
|
6
12
|
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
13
|
+
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
14
|
+
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
|
-
* Foreground service that
|
|
17
|
+
* Foreground service that **owns** the ExoPlayer and MediaSession.
|
|
10
18
|
*
|
|
11
|
-
* Media3's
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
19
|
+
* The player runs on the **main looper** so that Media3's
|
|
20
|
+
* [MediaSessionService] can access it directly for automatic notification
|
|
21
|
+
* management (foreground promotion, media-style notification, demotion).
|
|
22
|
+
*
|
|
23
|
+
* ExoPlayer does all heavy work (decoding, buffering, network I/O) on its
|
|
24
|
+
* own internal threads — the application looper only handles lightweight
|
|
25
|
+
* callbacks and control calls.
|
|
16
26
|
*/
|
|
27
|
+
@UnstableApi
|
|
17
28
|
class NitroPlayerPlaybackService : MediaSessionService() {
|
|
18
29
|
|
|
19
30
|
companion object {
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
const val ACTION_LOCAL_BIND = "com.margelo.nitro.nitroplayer.LOCAL_BIND"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Created in onCreate ────────────────────────────────────────────────
|
|
35
|
+
private lateinit var player: ExoPlayer
|
|
36
|
+
private var mediaSession: MediaSession? = null
|
|
37
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
38
|
+
|
|
39
|
+
@Volatile
|
|
40
|
+
var trackPlayerCore: TrackPlayerCore? = null
|
|
41
|
+
|
|
42
|
+
// ── Binder exposed to TrackPlayerCore ──────────────────────────────────
|
|
43
|
+
inner class LocalBinder : Binder() {
|
|
44
|
+
val service: NitroPlayerPlaybackService get() = this@NitroPlayerPlaybackService
|
|
45
|
+
val exoPlayer: ExoPlayer get() = player
|
|
46
|
+
val session: MediaSession get() = mediaSession!!
|
|
47
|
+
val handler: Handler get() = mainHandler
|
|
22
48
|
}
|
|
23
49
|
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
private val localBinder = lazy { LocalBinder() }
|
|
51
|
+
|
|
52
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
override fun onCreate() {
|
|
55
|
+
super.onCreate()
|
|
56
|
+
NitroPlayerLogger.log("PlaybackService") { "onCreate" }
|
|
57
|
+
|
|
58
|
+
// Build ExoPlayer on main looper (default)
|
|
59
|
+
player = ExoPlayerBuilder.build(this)
|
|
60
|
+
|
|
61
|
+
// Build MediaSession
|
|
62
|
+
val playlistManager = PlaylistManager.getInstance(this)
|
|
63
|
+
mediaSession = MediaSession
|
|
64
|
+
.Builder(this, player)
|
|
65
|
+
.setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
|
|
66
|
+
.build()
|
|
67
|
+
|
|
68
|
+
// Explicitly register the session with the service so that
|
|
69
|
+
// MediaNotificationManager (created in super.onCreate()) can
|
|
70
|
+
// connect its internal MediaController and post notifications.
|
|
71
|
+
addSession(mediaSession!!)
|
|
72
|
+
|
|
73
|
+
// Media3 automatically handles the notification via DefaultMediaNotificationProvider.
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
|
77
|
+
mediaSession
|
|
78
|
+
|
|
79
|
+
override fun onBind(intent: Intent?): IBinder? {
|
|
80
|
+
if (intent?.action == ACTION_LOCAL_BIND) {
|
|
81
|
+
return localBinder.value
|
|
82
|
+
}
|
|
83
|
+
return super.onBind(intent)
|
|
26
84
|
}
|
|
27
85
|
|
|
28
86
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
@@ -34,7 +92,10 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
34
92
|
}
|
|
35
93
|
|
|
36
94
|
override fun onDestroy() {
|
|
37
|
-
NitroPlayerLogger.log("PlaybackService") { "
|
|
95
|
+
NitroPlayerLogger.log("PlaybackService") { "onDestroy" }
|
|
96
|
+
mediaSession?.release()
|
|
97
|
+
mediaSession = null
|
|
98
|
+
player.release()
|
|
38
99
|
super.onDestroy()
|
|
39
100
|
}
|
|
40
101
|
}
|
|
@@ -216,13 +216,19 @@ extension TrackPlayerCore {
|
|
|
216
216
|
guard let player = self.player else { return }
|
|
217
217
|
|
|
218
218
|
let currentItem = player.currentItem
|
|
219
|
+
|
|
220
|
+
guard let playingTrackId = currentItem?.trackId else {
|
|
221
|
+
NitroPlayerLogger.log("TrackPlayerCore", "❌ No current item or track ID found during queue rebuild")
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
219
225
|
let playingItems = player.items()
|
|
220
226
|
|
|
221
227
|
// If the currently playing AVPlayerItem is no longer in currentTracks,
|
|
222
228
|
// delegate to rebuildQueueFromPlaylistIndex so the player immediately
|
|
223
229
|
// starts what is now at currentTrackIndex in the updated list.
|
|
224
|
-
if
|
|
225
|
-
|
|
230
|
+
if !currentTracks.contains(where: { $0.id == playingTrackId }) &&
|
|
231
|
+
currentTemporaryType == .none {
|
|
226
232
|
let targetIndex = currentTrackIndex < currentTracks.count
|
|
227
233
|
? currentTrackIndex : currentTracks.count - 1
|
|
228
234
|
if targetIndex >= 0 {
|
|
@@ -231,6 +237,14 @@ extension TrackPlayerCore {
|
|
|
231
237
|
return
|
|
232
238
|
}
|
|
233
239
|
|
|
240
|
+
// Sync currentTrackIndex to the track's actual position after a playlist mutation
|
|
241
|
+
// (e.g. reorder). Without this, the remaining-tracks slice uses the stale index,
|
|
242
|
+
// causing wrong tracks to play after skip/next.
|
|
243
|
+
if currentTemporaryType == .none,
|
|
244
|
+
let newIndex = currentTracks.firstIndex(where: { $0.id == playingTrackId }) {
|
|
245
|
+
currentTrackIndex = newIndex
|
|
246
|
+
}
|
|
247
|
+
|
|
234
248
|
// Build the desired upcoming track list
|
|
235
249
|
var newQueueTracks: [TrackItem] = []
|
|
236
250
|
let currentId = currentItem?.trackId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "0.7.1-alpha.
|
|
3
|
+
"version": "0.7.1-alpha.3",
|
|
4
4
|
"description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
|
|
5
5
|
"main": "lib/index",
|
|
6
6
|
"module": "lib/index",
|