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
|
@@ -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
|
-
|
|
14
|
-
playerThread: HandlerThread,
|
|
13
|
+
exoPlayer: ExoPlayer,
|
|
15
14
|
) {
|
|
16
|
-
/** The underlying ExoPlayer instance — accessible for
|
|
17
|
-
internal val player: ExoPlayer =
|
|
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.
|
|
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 —
|
|
34
|
+
/** Main-looper handler — used for player operations and Android Auto callbacks. */
|
|
30
35
|
internal val handler = Handler(Looper.getMainLooper())
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
8
|
-
* Called once from TrackPlayerCore.
|
|
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.
|
|
11
|
-
|
|
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,
|
|
15
|
-
setTrackPlayerCore(this@
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
229
|
+
notificationId,
|
|
213
230
|
notification,
|
|
214
231
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
215
232
|
)
|
|
216
233
|
} else {
|
|
217
|
-
ForegroundInfo(
|
|
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
|
|
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
|
+
}
|