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.
@@ -6,6 +6,7 @@
6
6
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
7
7
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
8
8
  <uses-permission android:name="android.permission.WAKE_LOCK" />
9
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
9
10
 
10
11
  <application>
11
12
  <!-- PlaybackService keeps music alive when screen is locked / app backgrounded -->
@@ -1,59 +1,19 @@
1
1
  package com.margelo.nitro.nitroplayer.core
2
2
 
3
- import android.content.Context
4
- import android.os.HandlerThread
5
- import androidx.media3.common.AudioAttributes
6
- import androidx.media3.common.C
7
3
  import androidx.media3.common.MediaItem
8
4
  import androidx.media3.common.Player
9
- import androidx.media3.exoplayer.DefaultLoadControl
10
5
  import androidx.media3.exoplayer.ExoPlayer
11
6
 
7
+ /**
8
+ * Thin wrapper around an [ExoPlayer] instance owned by the playback service.
9
+ * All delegation methods are unchanged — only the constructor now accepts an
10
+ * existing player instead of building one.
11
+ */
12
12
  class ExoPlayerCore(
13
- context: Context,
14
- playerThread: HandlerThread,
13
+ exoPlayer: ExoPlayer,
15
14
  ) {
16
- /** The underlying ExoPlayer instance — accessible for MediaSessionManager wiring. */
17
- internal val player: ExoPlayer = build(context, playerThread)
18
-
19
- private fun build(
20
- context: Context,
21
- playerThread: HandlerThread,
22
- ): ExoPlayer {
23
- val loadControl =
24
- DefaultLoadControl
25
- .Builder()
26
- .setBufferDurationsMs(
27
- // minBufferMs
28
- 30_000,
29
- // maxBufferMs
30
- 120_000,
31
- // bufferForPlayback
32
- 2_500,
33
- // bufferForRebuffer
34
- 5_000,
35
- ).setBackBuffer(30_000, /* retainBackBufferFromKeyframe */ true)
36
- .setTargetBufferBytes(C.LENGTH_UNSET)
37
- .setPrioritizeTimeOverSizeThresholds(true)
38
- .build()
39
-
40
- val audioAttrs =
41
- AudioAttributes
42
- .Builder()
43
- .setUsage(C.USAGE_MEDIA)
44
- .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
45
- .build()
46
-
47
- return ExoPlayer
48
- .Builder(context)
49
- .setLooper(playerThread.looper)
50
- .setLoadControl(loadControl)
51
- .setAudioAttributes(audioAttrs, /* handleAudioFocus */ true)
52
- .setHandleAudioBecomingNoisy(true)
53
- .setWakeMode(C.WAKE_MODE_NETWORK)
54
- .setPauseAtEndOfMediaItems(false)
55
- .build()
56
- }
15
+ /** The underlying ExoPlayer instance — accessible for wiring. */
16
+ internal val player: ExoPlayer = exoPlayer
57
17
 
58
18
  // ── Playback ───────────────────────────────────────────────────────────
59
19
  fun play() = player.play()
@@ -2,9 +2,12 @@
2
2
 
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
+ import android.content.ComponentName
5
6
  import android.content.Context
7
+ import android.content.Intent
8
+ import android.content.ServiceConnection
6
9
  import android.os.Handler
7
- import android.os.HandlerThread
10
+ import android.os.IBinder
8
11
  import android.os.Looper
9
12
  import com.margelo.nitro.nitroplayer.Reason
10
13
  import com.margelo.nitro.nitroplayer.RepeatMode
@@ -14,7 +17,9 @@ import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
14
17
  import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
15
18
  import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
16
19
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
20
+ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
17
21
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
22
+ import kotlinx.coroutines.CompletableDeferred
18
23
  import kotlinx.coroutines.CoroutineScope
19
24
  import kotlinx.coroutines.SupervisorJob
20
25
  import kotlinx.coroutines.cancel
@@ -26,13 +31,18 @@ class TrackPlayerCore private constructor(
26
31
  internal val context: Context,
27
32
  ) {
28
33
  // ── Thread infrastructure ──────────────────────────────────────────────
29
- /** Main-looper handler — only for Android Auto connection callbacks */
34
+ /** Main-looper handler — used for player operations and Android Auto callbacks. */
30
35
  internal val handler = Handler(Looper.getMainLooper())
31
- internal val playerThread = HandlerThread("NitroPlayer").apply { start() }
32
- internal val playerHandler = Handler(playerThread.looper)
36
+
37
+ /** Populated from the service binder. Player runs on main looper. */
38
+ internal lateinit var playerHandler: Handler
39
+
33
40
  internal val scope = CoroutineScope(SupervisorJob())
34
41
 
35
- // ── ExoPlayer wrapper (created on player thread inside initExoAndMedia) ──
42
+ /** Gates all player operations until the service is bound and init is complete. */
43
+ private val serviceReady = CompletableDeferred<Unit>()
44
+
45
+ // ── ExoPlayer wrapper (created on player thread inside initFromService) ──
36
46
  internal lateinit var exo: ExoPlayerCore
37
47
 
38
48
  /** Safe initialized check — backing field can only be read from the declaring class. */
@@ -112,6 +122,32 @@ class TrackPlayerCore private constructor(
112
122
  }
113
123
  }
114
124
 
125
+ // ── Service binding ────────────────────────────────────────────────────
126
+ private var serviceBound = false
127
+
128
+ private val serviceConnection =
129
+ object : ServiceConnection {
130
+ override fun onServiceConnected(
131
+ name: ComponentName?,
132
+ service: IBinder?,
133
+ ) {
134
+ val binder = service as NitroPlayerPlaybackService.LocalBinder
135
+ playerHandler = binder.handler
136
+ binder.service.trackPlayerCore = this@TrackPlayerCore
137
+ serviceBound = true
138
+
139
+ // Initialize on main thread (player now runs on main looper)
140
+ playerHandler.post {
141
+ initFromService(binder)
142
+ setupAndroidAutoDetector()
143
+ }
144
+ }
145
+
146
+ override fun onServiceDisconnected(name: ComponentName?) {
147
+ serviceBound = false
148
+ }
149
+ }
150
+
115
151
  // ── Singleton ──────────────────────────────────────────────────────────
116
152
  companion object {
117
153
  @Volatile
@@ -125,16 +161,26 @@ class TrackPlayerCore private constructor(
125
161
  }
126
162
 
127
163
  init {
128
- // ExoPlayer must be created on its own thread
129
- playerHandler.post { initExoAndMedia() }
130
- // Android Auto receiver must be registered on the main thread
131
- handler.post { setupAndroidAutoDetector() }
164
+ // Defer service start/bind to the main thread so it doesn't run
165
+ // synchronously on the JNI thread during HybridObject creation.
166
+ handler.post {
167
+ val intent = Intent(context, NitroPlayerPlaybackService::class.java)
168
+ context.startService(intent)
169
+
170
+ val bindIntent =
171
+ Intent(context, NitroPlayerPlaybackService::class.java).apply {
172
+ action = NitroPlayerPlaybackService.ACTION_LOCAL_BIND
173
+ }
174
+ context.bindService(bindIntent, serviceConnection, Context.BIND_AUTO_CREATE)
175
+ }
132
176
  }
133
177
 
134
- // ── Coroutine bridge to player thread ──────────────────────────────────
178
+ // ── Coroutine bridge to player looper (main thread) ────────────────────
135
179
 
136
180
  internal suspend fun <T> withPlayerContext(block: () -> T): T {
137
- if (Looper.myLooper() == playerThread.looper) return block()
181
+ // Wait until the service is bound and player is initialized
182
+ serviceReady.await()
183
+ if (Looper.myLooper() == playerHandler.looper) return block()
138
184
  return suspendCancellableCoroutine { cont ->
139
185
  val r =
140
186
  Runnable {
@@ -149,19 +195,33 @@ class TrackPlayerCore private constructor(
149
195
  }
150
196
  }
151
197
 
198
+ /** Called from initFromService once everything is wired up. */
199
+ internal fun completeServiceReady() {
200
+ serviceReady.complete(Unit)
201
+ }
202
+
152
203
  // ── Lifecycle ──────────────────────────────────────────────────────────
153
204
 
154
205
  fun destroy() {
155
- playerHandler.post {
156
- androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
157
- playerHandler.removeCallbacks(progressUpdateRunnable)
158
- if (::exo.isInitialized) {
159
- playerListener?.let { exo.removeListener(it) }
206
+ if (::playerHandler.isInitialized) {
207
+ playerHandler.post {
208
+ androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
209
+ playerHandler.removeCallbacks(progressUpdateRunnable)
210
+ if (::exo.isInitialized) {
211
+ playerListener?.let { exo.removeListener(it) }
212
+ }
213
+ playerListener = null
160
214
  }
161
- playerListener = null
162
215
  }
163
216
  scope.cancel()
164
- playerThread.quitSafely()
217
+ // Do NOT stop the service — it owns the player.
218
+ // Unbind so Android can clean up if needed.
219
+ if (serviceBound) {
220
+ try {
221
+ context.unbindService(serviceConnection)
222
+ } catch (_: Exception) {}
223
+ serviceBound = false
224
+ }
165
225
  }
166
226
 
167
227
  // ── Simple read-only accessors ─────────────────────────────────────────
@@ -12,7 +12,7 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
12
12
  /**
13
13
  * ExoPlayer event listener — translates low-level ExoPlayer callbacks into
14
14
  * TrackPlayerCore state mutations and JS-facing listener notifications.
15
- * All callbacks fire on the player thread (ExoPlayerCore is built with playerThread.looper).
15
+ * All callbacks fire on the main looper (ExoPlayer uses the default application looper).
16
16
  */
17
17
  internal class TrackPlayerEventListener(
18
18
  private val core: TrackPlayerCore,
@@ -45,12 +45,26 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
45
45
 
46
46
  // If current track was removed from the playlist, jump to best substitute
47
47
  val currentTrackId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
48
- if (currentTrackId != null && currentTracks.none { it.id == currentTrackId }) {
48
+
49
+ if (
50
+ currentTrackId != null &&
51
+ currentTracks.none { it.id == currentTrackId } &&
52
+ currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
53
+ ) {
49
54
  if (currentTracks.isEmpty()) return
50
55
  playFromIndexInternal(minOf(currentTrackIndex, currentTracks.size - 1))
51
56
  return
52
57
  }
53
58
 
59
+ // Keep the logical playlist pointer in sync after playlist mutations.
60
+ // Without this, getActualQueue/getState can report a stale index until the next track transition.
61
+ if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && currentTrackId != null) {
62
+ val resolvedIndex = currentTracks.indexOfFirst { it.id == currentTrackId }
63
+ if (resolvedIndex >= 0) {
64
+ currentTrackIndex = resolvedIndex
65
+ }
66
+ }
67
+
54
68
  val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
55
69
  val currentId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
56
70
 
@@ -1,18 +1,22 @@
1
1
  package com.margelo.nitro.nitroplayer.core
2
2
 
3
+ import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
3
4
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
4
5
  import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
6
+ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
5
7
 
6
8
  /**
7
- * Initialises ExoPlayer (via ExoPlayerCore) and MediaSessionManager on the player thread.
8
- * Called once from TrackPlayerCore.init via playerHandler.post.
9
+ * Initialises ExoPlayerCore wrapper and MediaSessionManager from the service binder.
10
+ * Called once from TrackPlayerCore's ServiceConnection.onServiceConnected via playerHandler.post.
9
11
  */
10
- internal fun TrackPlayerCore.initExoAndMedia() {
11
- exo = ExoPlayerCore(context, playerThread)
12
+ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.LocalBinder) {
13
+ // Wrap the service-owned ExoPlayer
14
+ exo = ExoPlayerCore(binder.exoPlayer)
12
15
 
16
+ // Wrap the service-owned MediaSession (no longer creates its own)
13
17
  mediaSessionManager =
14
- MediaSessionManager(context, exo.player, playlistManager).apply {
15
- setTrackPlayerCore(this@initExoAndMedia)
18
+ MediaSessionManager(context, binder.session, playlistManager).apply {
19
+ setTrackPlayerCore(this@initFromService)
16
20
  }
17
21
 
18
22
  // Give MediaBrowserService access to this core and media session
@@ -24,6 +28,20 @@ internal fun TrackPlayerCore.initExoAndMedia() {
24
28
  playerListener = listener
25
29
  exo.addListener(listener)
26
30
 
27
- // Start progress ticks on the player thread
31
+ // The audio session ID is assigned during ExoPlayer construction (in
32
+ // PlaybackService.onCreate), before our listener is attached.
33
+ // onAudioSessionIdChanged only fires on *changes*, so we'd miss the
34
+ // initial value. Manually feed it to the equalizer now.
35
+ val sessionId = binder.exoPlayer.audioSessionId
36
+ if (sessionId != 0) {
37
+ try {
38
+ EqualizerCore.getInstance(context).initialize(sessionId)
39
+ } catch (_: Exception) { }
40
+ }
41
+
42
+ // Start progress ticks on the main looper
28
43
  playerHandler.postDelayed(progressUpdateRunnable, 250)
44
+
45
+ // Signal that the player is ready — unblocks all withPlayerContext callers
46
+ completeServiceReady()
29
47
  }
@@ -111,6 +111,7 @@ class DownloadManagerCore private constructor(
111
111
  workDataOf(
112
112
  DownloadWorker.KEY_DOWNLOAD_ID to downloadId,
113
113
  DownloadWorker.KEY_TRACK_ID to track.id,
114
+ DownloadWorker.KEY_TRACK_TITLE to track.title,
114
115
  DownloadWorker.KEY_URL to track.url,
115
116
  DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
116
117
  DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
@@ -169,6 +170,7 @@ class DownloadManagerCore private constructor(
169
170
  workDataOf(
170
171
  DownloadWorker.KEY_DOWNLOAD_ID to downloadId,
171
172
  DownloadWorker.KEY_TRACK_ID to track.id,
173
+ DownloadWorker.KEY_TRACK_TITLE to track.title,
172
174
  DownloadWorker.KEY_URL to track.url,
173
175
  DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
174
176
  DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
@@ -13,7 +13,6 @@ import com.margelo.nitro.nitroplayer.*
13
13
  import kotlinx.coroutines.Dispatchers
14
14
  import kotlinx.coroutines.withContext
15
15
  import java.io.BufferedInputStream
16
- import java.io.File
17
16
  import java.io.FileOutputStream
18
17
  import java.net.HttpURLConnection
19
18
  import java.net.URL
@@ -28,26 +27,34 @@ class DownloadWorker(
28
27
  companion object {
29
28
  const val KEY_DOWNLOAD_ID = "download_id"
30
29
  const val KEY_TRACK_ID = "track_id"
30
+ const val KEY_TRACK_TITLE = "track_title"
31
31
  const val KEY_URL = "url"
32
32
  const val KEY_PLAYLIST_ID = "playlist_id"
33
33
  const val KEY_STORAGE_LOCATION = "storage_location"
34
34
 
35
35
  private const val NOTIFICATION_CHANNEL_ID = "nitro_player_downloads"
36
- private const val NOTIFICATION_ID = 2001
36
+ private const val BASE_NOTIFICATION_ID = 2001
37
37
  private const val BUFFER_SIZE = 8192
38
38
  private val CONTENT_DISPOSITION_REGEX = Regex("filename=\"?([^\";]+)\"?")
39
39
  }
40
40
 
41
41
  private val downloadManager = DownloadManagerCore.getInstance(context)
42
42
  private val fileManager = DownloadFileManager.getInstance(context)
43
+ private val notificationManager =
44
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
45
+ /** Stable notification ID per download — derived from trackId hash. */
46
+ private var notificationId = BASE_NOTIFICATION_ID
43
47
 
44
48
  override suspend fun doWork(): Result =
45
49
  withContext(Dispatchers.IO) {
46
50
  val downloadId = inputData.getString(KEY_DOWNLOAD_ID) ?: return@withContext Result.failure()
47
51
  val trackId = inputData.getString(KEY_TRACK_ID) ?: return@withContext Result.failure()
52
+ val trackTitle = inputData.getString(KEY_TRACK_TITLE) ?: "Unknown track"
48
53
  val urlString = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
49
54
  val storageLocationStr = inputData.getString(KEY_STORAGE_LOCATION) ?: StorageLocation.PRIVATE.name
50
55
 
56
+ notificationId = BASE_NOTIFICATION_ID + trackId.hashCode().and(0xFFFF)
57
+
51
58
  val storageLocation =
52
59
  try {
53
60
  StorageLocation.valueOf(storageLocationStr)
@@ -56,14 +63,21 @@ class DownloadWorker(
56
63
  }
57
64
 
58
65
  try {
59
- // Set foreground notification
60
- setForeground(createForegroundInfo(trackId))
66
+ // Set foreground notification — if POST_NOTIFICATIONS is denied,
67
+ // WorkManager still runs the task; the notification just won't show.
68
+ try {
69
+ setForeground(createForegroundInfo(trackTitle, 0, true))
70
+ } catch (_: Exception) {
71
+ // Foreground promotion failed (e.g. missing permission on some OEMs).
72
+ // Download continues in background.
73
+ }
61
74
 
62
75
  // Perform download
63
- val localPath = downloadFile(downloadId, trackId, urlString, storageLocation)
76
+ val localPath = downloadFile(downloadId, trackId, trackTitle, urlString, storageLocation)
64
77
 
65
78
  if (localPath != null) {
66
79
  downloadManager.onComplete(downloadId, trackId, localPath)
80
+ showCompletionNotification(trackTitle)
67
81
  Result.success()
68
82
  } else {
69
83
  val error =
@@ -74,6 +88,7 @@ class DownloadWorker(
74
88
  isRetryable = true,
75
89
  )
76
90
  downloadManager.onError(downloadId, trackId, error)
91
+ showErrorNotification(trackTitle)
77
92
  Result.retry()
78
93
  }
79
94
  } catch (e: Exception) {
@@ -93,6 +108,7 @@ class DownloadWorker(
93
108
  isRetryable = errorReason in listOf(DownloadErrorReason.NETWORK_ERROR, DownloadErrorReason.TIMEOUT),
94
109
  )
95
110
  downloadManager.onError(downloadId, trackId, error)
111
+ showErrorNotification(trackTitle)
96
112
 
97
113
  if (error.isRetryable) {
98
114
  Result.retry()
@@ -105,6 +121,7 @@ class DownloadWorker(
105
121
  private suspend fun downloadFile(
106
122
  downloadId: String,
107
123
  trackId: String,
124
+ trackTitle: String,
108
125
  urlString: String,
109
126
  storageLocation: StorageLocation,
110
127
  ): String? =
@@ -167,10 +184,12 @@ class DownloadWorker(
167
184
  outputStream.write(buffer, 0, bytesRead)
168
185
  bytesDownloaded += bytesRead
169
186
 
170
- // Update progress every 250ms
187
+ // Update progress every 250ms — both the callback and the notification
171
188
  val now = System.currentTimeMillis()
172
189
  if (now - lastProgressUpdate >= 250) {
173
190
  downloadManager.onProgress(downloadId, trackId, bytesDownloaded, totalBytes)
191
+ val percent = if (totalBytes > 0) ((bytesDownloaded * 100) / totalBytes).toInt() else 0
192
+ updateProgressNotification(trackTitle, percent)
174
193
  lastProgressUpdate = now
175
194
  }
176
195
  }
@@ -194,31 +213,85 @@ class DownloadWorker(
194
213
  }
195
214
  }
196
215
 
197
- private fun createForegroundInfo(trackId: String): ForegroundInfo {
198
- createNotificationChannel()
216
+ // ── Notification helpers ──────────────────────────────────────────────
217
+
218
+ private fun createForegroundInfo(
219
+ trackTitle: String,
220
+ percent: Int,
221
+ indeterminate: Boolean,
222
+ ): ForegroundInfo {
223
+ ensureNotificationChannel()
199
224
 
200
- val notification =
201
- NotificationCompat
202
- .Builder(context, NOTIFICATION_CHANNEL_ID)
203
- .setContentTitle("Downloading")
204
- .setContentText("Downloading track...")
205
- .setSmallIcon(android.R.drawable.stat_sys_download)
206
- .setOngoing(true)
207
- .setProgress(100, 0, true)
208
- .build()
225
+ val notification = buildProgressNotification(trackTitle, percent, indeterminate)
209
226
 
210
227
  return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
211
228
  ForegroundInfo(
212
- NOTIFICATION_ID,
229
+ notificationId,
213
230
  notification,
214
231
  android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
215
232
  )
216
233
  } else {
217
- ForegroundInfo(NOTIFICATION_ID, notification)
234
+ ForegroundInfo(notificationId, notification)
235
+ }
236
+ }
237
+
238
+ private fun buildProgressNotification(
239
+ trackTitle: String,
240
+ percent: Int,
241
+ indeterminate: Boolean,
242
+ ) = NotificationCompat
243
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
244
+ .setContentTitle("Downloading")
245
+ .setContentText(trackTitle)
246
+ .setSubText(if (!indeterminate) "$percent%" else null)
247
+ .setSmallIcon(android.R.drawable.stat_sys_download)
248
+ .setOngoing(true)
249
+ .setOnlyAlertOnce(true)
250
+ .setProgress(100, percent, indeterminate)
251
+ .build()
252
+
253
+ private fun updateProgressNotification(trackTitle: String, percent: Int) {
254
+ try {
255
+ notificationManager.notify(
256
+ notificationId,
257
+ buildProgressNotification(trackTitle, percent, false),
258
+ )
259
+ } catch (_: SecurityException) {
260
+ // POST_NOTIFICATIONS not granted — download continues silently
218
261
  }
219
262
  }
220
263
 
221
- private fun createNotificationChannel() {
264
+ private fun showCompletionNotification(trackTitle: String) {
265
+ try {
266
+ notificationManager.notify(
267
+ notificationId,
268
+ NotificationCompat
269
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
270
+ .setContentTitle("Download complete")
271
+ .setContentText(trackTitle)
272
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
273
+ .setAutoCancel(true)
274
+ .build(),
275
+ )
276
+ } catch (_: SecurityException) { }
277
+ }
278
+
279
+ private fun showErrorNotification(trackTitle: String) {
280
+ try {
281
+ notificationManager.notify(
282
+ notificationId,
283
+ NotificationCompat
284
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
285
+ .setContentTitle("Download failed")
286
+ .setContentText(trackTitle)
287
+ .setSmallIcon(android.R.drawable.stat_notify_error)
288
+ .setAutoCancel(true)
289
+ .build(),
290
+ )
291
+ } catch (_: SecurityException) { }
292
+ }
293
+
294
+ private fun ensureNotificationChannel() {
222
295
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
223
296
  val channel =
224
297
  NotificationChannel(
@@ -228,9 +301,7 @@ class DownloadWorker(
228
301
  ).apply {
229
302
  description = "Download progress notifications"
230
303
  }
231
-
232
- val notificationManager = context.getSystemService(NotificationManager::class.java)
233
- notificationManager?.createNotificationChannel(channel)
304
+ notificationManager.createNotificationChannel(channel)
234
305
  }
235
306
  }
236
307
  }
@@ -0,0 +1,49 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import android.content.Context
4
+ import androidx.media3.common.AudioAttributes
5
+ import androidx.media3.common.C
6
+ import androidx.media3.exoplayer.DefaultLoadControl
7
+ import androidx.media3.exoplayer.ExoPlayer
8
+
9
+ /**
10
+ * Builds an [ExoPlayer] with the standard NitroPlayer configuration.
11
+ * The player uses the main application looper so that Media3's
12
+ * [MediaSessionService] notification system works without thread conflicts.
13
+ */
14
+ object ExoPlayerBuilder {
15
+ fun build(context: Context): ExoPlayer {
16
+ val loadControl =
17
+ DefaultLoadControl
18
+ .Builder()
19
+ .setBufferDurationsMs(
20
+ // minBufferMs
21
+ 30_000,
22
+ // maxBufferMs
23
+ 120_000,
24
+ // bufferForPlayback
25
+ 2_500,
26
+ // bufferForRebuffer
27
+ 5_000,
28
+ ).setBackBuffer(30_000, /* retainBackBufferFromKeyframe */ true)
29
+ .setTargetBufferBytes(C.LENGTH_UNSET)
30
+ .setPrioritizeTimeOverSizeThresholds(true)
31
+ .build()
32
+
33
+ val audioAttrs =
34
+ AudioAttributes
35
+ .Builder()
36
+ .setUsage(C.USAGE_MEDIA)
37
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
38
+ .build()
39
+
40
+ return ExoPlayer
41
+ .Builder(context)
42
+ .setLoadControl(loadControl)
43
+ .setAudioAttributes(audioAttrs, /* handleAudioFocus */ true)
44
+ .setHandleAudioBecomingNoisy(true)
45
+ .setWakeMode(C.WAKE_MODE_NETWORK)
46
+ .setPauseAtEndOfMediaItems(false)
47
+ .build()
48
+ }
49
+ }