react-native-nitro-player 1.6.0 → 1.7.0-alpha.0

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.
Files changed (71) hide show
  1. package/README.md +6 -1
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
  21. package/android/src/main/res/values/strings.xml +7 -0
  22. package/ios/HybridDownloadManager.swift +13 -3
  23. package/ios/HybridTrackPlayer.swift +4 -2
  24. package/ios/core/TrackPlayerCast.swift +32 -1
  25. package/ios/core/TrackPlayerCore.swift +19 -3
  26. package/ios/core/TrackPlayerListener.swift +15 -16
  27. package/ios/core/TrackPlayerNotify.swift +11 -11
  28. package/ios/core/TrackPlayerPlayback.swift +54 -16
  29. package/ios/core/TrackPlayerQueueBuild.swift +14 -2
  30. package/ios/core/TrackPlayerTempQueue.swift +1 -0
  31. package/ios/core/TrackPlayerUrlLoader.swift +11 -3
  32. package/ios/download/DownloadDatabase.swift +16 -5
  33. package/ios/download/DownloadFileManager.swift +12 -15
  34. package/ios/download/DownloadManagerCore.swift +61 -16
  35. package/ios/equalizer/EqualizerCore.swift +37 -9
  36. package/ios/media/MediaSessionManager.swift +89 -4
  37. package/ios/playlist/PlaylistManager.swift +47 -46
  38. package/ios/queue/HybridPlayerQueue.swift +28 -7
  39. package/lib/hooks/callbackManager.d.ts +9 -0
  40. package/lib/hooks/callbackManager.js +33 -0
  41. package/lib/hooks/useActualQueue.js +18 -6
  42. package/lib/hooks/useAndroidAutoConnection.js +2 -4
  43. package/lib/hooks/useAudioDevices.js +21 -24
  44. package/lib/hooks/useDownloadActions.js +33 -11
  45. package/lib/hooks/useDownloadProgress.js +20 -14
  46. package/lib/hooks/useDownloadStorage.js +1 -1
  47. package/lib/hooks/useNowPlaying.js +32 -6
  48. package/lib/hooks/useOnSeek.js +2 -2
  49. package/lib/hooks/usePlaylist.js +6 -15
  50. package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
  51. package/lib/types/PlayerQueue.d.ts +8 -0
  52. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
  54. package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
  55. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
  56. package/package.json +1 -1
  57. package/src/hooks/callbackManager.ts +44 -0
  58. package/src/hooks/useActualQueue.ts +18 -6
  59. package/src/hooks/useAndroidAutoConnection.ts +6 -6
  60. package/src/hooks/useAudioDevices.ts +23 -24
  61. package/src/hooks/useDownloadActions.ts +35 -11
  62. package/src/hooks/useDownloadProgress.ts +22 -19
  63. package/src/hooks/useDownloadStorage.ts +4 -3
  64. package/src/hooks/useNowPlaying.ts +34 -6
  65. package/src/hooks/useOnSeek.ts +2 -2
  66. package/src/hooks/usePlaylist.ts +6 -14
  67. package/src/specs/TrackPlayer.nitro.ts +1 -2
  68. package/src/types/PlayerQueue.ts +8 -0
  69. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
  70. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
  71. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
@@ -1,9 +1,14 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { Platform } from 'react-native'
3
- import { NitroModules } from 'react-native-nitro-modules'
4
- import type { AudioDevices as AudioDevicesType } from '../specs/AudioDevices.nitro'
2
+ import { AudioDevices } from '../index'
5
3
  import type { TAudioDevice } from '../specs/AudioDevices.nitro'
6
4
 
5
+ function devicesEqual(a: TAudioDevice[], b: TAudioDevice[]): boolean {
6
+ if (a.length !== b.length) return false
7
+ return a.every(
8
+ (d, i) => d.id === b[i]!.id && d.isActive === b[i]!.isActive
9
+ )
10
+ }
11
+
7
12
  /**
8
13
  * Hook to get audio devices (Android only)
9
14
  *
@@ -30,34 +35,28 @@ export function useAudioDevices() {
30
35
  const [devices, setDevices] = useState<TAudioDevice[]>([])
31
36
 
32
37
  useEffect(() => {
33
- if (Platform.OS !== 'android') {
38
+ if (!AudioDevices) {
34
39
  return undefined
35
40
  }
36
41
 
37
- try {
38
- const AudioDevices =
39
- NitroModules.createHybridObject<AudioDevicesType>('AudioDevices')
40
-
41
- // Get initial devices
42
- const updateDevices = () => {
43
- try {
44
- const currentDevices = AudioDevices.getAudioDevices()
45
- setDevices(currentDevices)
46
- } catch (error) {
47
- console.error('Error getting audio devices:', error)
48
- }
42
+ const updateDevices = () => {
43
+ try {
44
+ const currentDevices = AudioDevices!.getAudioDevices()
45
+ // Bail when nothing changed — the bridge returns a fresh array identity
46
+ // every tick, which would otherwise re-render every consumer at 0.5Hz
47
+ setDevices((prev) =>
48
+ devicesEqual(prev, currentDevices) ? prev : currentDevices
49
+ )
50
+ } catch (error) {
51
+ console.error('Error getting audio devices:', error)
49
52
  }
53
+ }
50
54
 
51
- updateDevices()
55
+ updateDevices()
52
56
 
53
- // Poll for changes every 2 seconds
54
- const interval = setInterval(updateDevices, 2000)
57
+ const interval = setInterval(updateDevices, 2000)
55
58
 
56
- return () => clearInterval(interval)
57
- } catch (error) {
58
- console.error('Error setting up audio devices polling:', error)
59
- return undefined
60
- }
59
+ return () => clearInterval(interval)
61
60
  }, [])
62
61
 
63
62
  return { devices }
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useRef, useState } from 'react'
2
2
  import { DownloadManager } from '../index'
3
3
  import type { DownloadConfig, PlaybackSource } from '../types/DownloadTypes'
4
4
  import type { TrackItem } from '../types/PlayerQueue'
@@ -45,10 +45,34 @@ export function useDownloadActions(): UseDownloadActionsResult {
45
45
  const [isDownloading, setIsDownloading] = useState(false)
46
46
  const [isDeleting, setIsDeleting] = useState(false)
47
47
  const [error, setError] = useState<Error | null>(null)
48
+ // Pending counts: overlapping calls otherwise clear the flag while a
49
+ // second request is still in flight
50
+ const downloadingCount = useRef(0)
51
+ const deletingCount = useRef(0)
52
+
53
+ const beginDownloading = useCallback(() => {
54
+ downloadingCount.current += 1
55
+ setIsDownloading(true)
56
+ }, [])
57
+
58
+ const endDownloading = useCallback(() => {
59
+ downloadingCount.current = Math.max(0, downloadingCount.current - 1)
60
+ if (downloadingCount.current === 0) setIsDownloading(false)
61
+ }, [])
62
+
63
+ const beginDeleting = useCallback(() => {
64
+ deletingCount.current += 1
65
+ setIsDeleting(true)
66
+ }, [])
67
+
68
+ const endDeleting = useCallback(() => {
69
+ deletingCount.current = Math.max(0, deletingCount.current - 1)
70
+ if (deletingCount.current === 0) setIsDeleting(false)
71
+ }, [])
48
72
 
49
73
  const downloadTrack = useCallback(
50
74
  async (track: TrackItem, playlistId?: string) => {
51
- setIsDownloading(true)
75
+ beginDownloading()
52
76
  setError(null)
53
77
  try {
54
78
  const downloadId = await DownloadManager.downloadTrack(
@@ -60,7 +84,7 @@ export function useDownloadActions(): UseDownloadActionsResult {
60
84
  setError(e as Error)
61
85
  throw e
62
86
  } finally {
63
- setIsDownloading(false)
87
+ endDownloading()
64
88
  }
65
89
  },
66
90
  []
@@ -68,7 +92,7 @@ export function useDownloadActions(): UseDownloadActionsResult {
68
92
 
69
93
  const downloadPlaylist = useCallback(
70
94
  async (playlistId: string, tracks: TrackItem[]) => {
71
- setIsDownloading(true)
95
+ beginDownloading()
72
96
  setError(null)
73
97
  try {
74
98
  const downloadIds = await DownloadManager.downloadPlaylist(
@@ -80,7 +104,7 @@ export function useDownloadActions(): UseDownloadActionsResult {
80
104
  setError(e as Error)
81
105
  throw e
82
106
  } finally {
83
- setIsDownloading(false)
107
+ endDownloading()
84
108
  }
85
109
  },
86
110
  []
@@ -115,29 +139,29 @@ export function useDownloadActions(): UseDownloadActionsResult {
115
139
  }, [])
116
140
 
117
141
  const deleteTrack = useCallback(async (trackId: string) => {
118
- setIsDeleting(true)
142
+ beginDeleting()
119
143
  try {
120
144
  await DownloadManager.deleteDownloadedTrack(trackId)
121
145
  } finally {
122
- setIsDeleting(false)
146
+ endDeleting()
123
147
  }
124
148
  }, [])
125
149
 
126
150
  const deletePlaylist = useCallback(async (playlistId: string) => {
127
- setIsDeleting(true)
151
+ beginDeleting()
128
152
  try {
129
153
  await DownloadManager.deleteDownloadedPlaylist(playlistId)
130
154
  } finally {
131
- setIsDeleting(false)
155
+ endDeleting()
132
156
  }
133
157
  }, [])
134
158
 
135
159
  const deleteAll = useCallback(async () => {
136
- setIsDeleting(true)
160
+ beginDeleting()
137
161
  try {
138
162
  await DownloadManager.deleteAllDownloads()
139
163
  } finally {
140
- setIsDeleting(false)
164
+ endDeleting()
141
165
  }
142
166
  }, [])
143
167
 
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useRef, useCallback } from 'react'
1
+ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
2
2
  import { DownloadManager } from '../index'
3
3
  import { downloadCallbackManager } from './downloadCallbackManager'
4
4
  import type { DownloadProgress } from '../types/DownloadTypes'
@@ -37,20 +37,19 @@ export function useDownloadProgress(
37
37
  )
38
38
  const isMounted = useRef(true)
39
39
 
40
+ // Content keys: callers pass inline arrays, so identity-based deps would
41
+ // resubscribe (and re-fetch over the bridge) on every parent render
42
+ const trackIdsKey = JSON.stringify(trackIds ?? [])
43
+ const downloadIdsKey = JSON.stringify(downloadIds ?? [])
44
+
40
45
  const shouldTrack = useCallback(
41
46
  (progress: DownloadProgress): boolean => {
42
- if (
43
- trackIds &&
44
- trackIds.length > 0 &&
45
- !trackIds.includes(progress.trackId)
46
- ) {
47
+ const ids: string[] = JSON.parse(trackIdsKey)
48
+ const dlIds: string[] = JSON.parse(downloadIdsKey)
49
+ if (ids.length > 0 && !ids.includes(progress.trackId)) {
47
50
  return false
48
51
  }
49
- if (
50
- downloadIds &&
51
- downloadIds.length > 0 &&
52
- !downloadIds.includes(progress.downloadId)
53
- ) {
52
+ if (dlIds.length > 0 && !dlIds.includes(progress.downloadId)) {
54
53
  return false
55
54
  }
56
55
  if (activeOnly && progress.state !== 'downloading') {
@@ -58,7 +57,7 @@ export function useDownloadProgress(
58
57
  }
59
58
  return true
60
59
  },
61
- [trackIds, downloadIds, activeOnly]
60
+ [trackIdsKey, downloadIdsKey, activeOnly]
62
61
  )
63
62
 
64
63
  useEffect(() => {
@@ -113,13 +112,17 @@ export function useDownloadProgress(
113
112
  }
114
113
  }, [shouldTrack])
115
114
 
116
- const progressList = Array.from(progressMap.values())
117
- const overallProgress =
118
- progressList.length > 0
119
- ? progressList.reduce((sum, p) => sum + p.progress, 0) /
120
- progressList.length
121
- : 0
122
- const isDownloading = progressList.some((p) => p.state === 'downloading')
115
+ const { progressList, overallProgress, isDownloading } = useMemo(() => {
116
+ const list = Array.from(progressMap.values())
117
+ return {
118
+ progressList: list,
119
+ overallProgress:
120
+ list.length > 0
121
+ ? list.reduce((sum, p) => sum + p.progress, 0) / list.length
122
+ : 0,
123
+ isDownloading: list.some((p) => p.state === 'downloading'),
124
+ }
125
+ }, [progressMap])
123
126
  const getProgress = useCallback(
124
127
  (trackId: string) => progressMap.get(trackId),
125
128
  [progressMap]
@@ -69,9 +69,10 @@ export function useDownloadStorage(): UseDownloadStorageResult {
69
69
  ? formatBytes(storageInfo.availableSpace)
70
70
  : '0 B'
71
71
 
72
- const usagePercentage = storageInfo
73
- ? (storageInfo.totalDownloadedSize / storageInfo.totalSpace) * 100
74
- : 0
72
+ const usagePercentage =
73
+ storageInfo && storageInfo.totalSpace > 0
74
+ ? (storageInfo.totalDownloadedSize / storageInfo.totalSpace) * 100
75
+ : 0
75
76
 
76
77
  return {
77
78
  storageInfo,
@@ -50,6 +50,11 @@ const DEFAULT_STATE: PlayerState = {
50
50
  export function useNowPlaying(): PlayerState {
51
51
  const [state, setState] = useState<PlayerState>(DEFAULT_STATE)
52
52
  const isMounted = useRef(true)
53
+ // Once pushed events carry position, an async getState() snapshot must not
54
+ // overwrite it — a slow snapshot resolving after newer ticks jumped the
55
+ // progress bar backward on play/pause/track transitions.
56
+ const eventsOwnPosition = useRef(false)
57
+ const fetchScheduled = useRef(false)
53
58
 
54
59
  const fetchFullState = useCallback(async () => {
55
60
  if (!isMounted.current) return
@@ -57,13 +62,32 @@ export function useNowPlaying(): PlayerState {
57
62
  try {
58
63
  const newState = await TrackPlayer.getState()
59
64
  if (isMounted.current) {
60
- setState(newState)
65
+ setState((prev) =>
66
+ eventsOwnPosition.current
67
+ ? {
68
+ ...newState,
69
+ currentPosition: prev.currentPosition,
70
+ totalDuration: prev.totalDuration,
71
+ }
72
+ : newState
73
+ )
61
74
  }
62
75
  } catch (error) {
63
76
  console.error('[useNowPlaying] Error updating player state:', error)
64
77
  }
65
78
  }, [])
66
79
 
80
+ // Coalesces the track-change + playback-state burst that fires on one
81
+ // transition into a single getState() round trip
82
+ const requestFullState = useCallback(() => {
83
+ if (fetchScheduled.current) return
84
+ fetchScheduled.current = true
85
+ queueMicrotask(() => {
86
+ fetchScheduled.current = false
87
+ fetchFullState()
88
+ })
89
+ }, [fetchFullState])
90
+
67
91
  // Initialize with current state
68
92
  useEffect(() => {
69
93
  isMounted.current = true
@@ -74,19 +98,21 @@ export function useNowPlaying(): PlayerState {
74
98
  }
75
99
  }, [fetchFullState])
76
100
 
77
- // Subscribe to track changes — full refresh
101
+ // Subscribe to track changes — full refresh; the new track's position comes
102
+ // from the snapshot, so release event ownership until ticks resume
78
103
  useEffect(() => {
79
104
  return callbackManager.subscribeToTrackChange(() => {
80
- fetchFullState()
105
+ eventsOwnPosition.current = false
106
+ requestFullState()
81
107
  })
82
- }, [fetchFullState])
108
+ }, [requestFullState])
83
109
 
84
110
  // Subscribe to playback state changes — full refresh
85
111
  useEffect(() => {
86
112
  return callbackManager.subscribeToPlaybackState(() => {
87
- fetchFullState()
113
+ requestFullState()
88
114
  })
89
- }, [fetchFullState])
115
+ }, [requestFullState])
90
116
 
91
117
  // Subscribe to progress changes — lightweight position/duration update.
92
118
  // Bail out when neither value changed at whole-second resolution: the native tick
@@ -95,6 +121,7 @@ export function useNowPlaying(): PlayerState {
95
121
  return callbackManager.subscribeToPlaybackProgressChange(
96
122
  (currentPosition, totalDuration) => {
97
123
  if (!isMounted.current) return
124
+ eventsOwnPosition.current = true
98
125
  setState((prev) => {
99
126
  if (
100
127
  Math.floor(prev.currentPosition) === Math.floor(currentPosition) &&
@@ -112,6 +139,7 @@ export function useNowPlaying(): PlayerState {
112
139
  useEffect(() => {
113
140
  return callbackManager.subscribeToSeek((currentPosition, totalDuration) => {
114
141
  if (!isMounted.current) return
142
+ eventsOwnPosition.current = true
115
143
  setState((prev) => ({ ...prev, currentPosition, totalDuration }))
116
144
  })
117
145
  }, [])
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { TrackPlayer } from '../index'
2
+ import { callbackManager } from './callbackManager'
3
3
 
4
4
  /**
5
5
  * Hook to get the last seek event information
@@ -15,7 +15,7 @@ export function useOnSeek(): {
15
15
  )
16
16
 
17
17
  useEffect(() => {
18
- TrackPlayer.onSeek((newPosition, newTotalDuration) => {
18
+ return callbackManager.subscribeToSeek((newPosition, newTotalDuration) => {
19
19
  setPosition(newPosition)
20
20
  setTotalDuration(newTotalDuration)
21
21
  })
@@ -59,7 +59,6 @@ export function usePlaylist(): UsePlaylistResult {
59
59
  const [allTracks, setAllTracks] = useState<TrackItem[]>([])
60
60
  const [isLoading, setIsLoading] = useState(true)
61
61
  const isMounted = useRef(true)
62
- const hasSubscribed = useRef(false)
63
62
  // Tracks the last fetched playlist ID so track-change events can skip
64
63
  // a full refresh when the playlist itself hasn't changed.
65
64
  const lastPlaylistIdRef = useRef<string | null | undefined>(undefined)
@@ -143,20 +142,13 @@ export function usePlaylist(): UsePlaylistResult {
143
142
  }
144
143
  }, [refreshPlaylists])
145
144
 
146
- // Subscribe to native playlist changes (only once)
145
+ // Subscribe to native playlist changes via the shared manager
147
146
  useEffect(() => {
148
- if (hasSubscribed.current) return
149
- hasSubscribed.current = true
150
-
151
- try {
152
- PlayerQueue.onPlaylistsChanged(() => {
153
- if (isMounted.current) {
154
- refreshPlaylists()
155
- }
156
- })
157
- } catch (error) {
158
- console.error('[usePlaylist] Error setting up playlist listener:', error)
159
- }
147
+ return callbackManager.subscribeToPlaylistsChanged(() => {
148
+ if (isMounted.current) {
149
+ refreshPlaylists()
150
+ }
151
+ })
160
152
  }, [refreshPlaylists])
161
153
 
162
154
  // Refresh on track change only if the active playlist ID changed.
@@ -157,8 +157,7 @@ export interface TrackPlayer extends HybridObject<{
157
157
  ): void
158
158
 
159
159
  /**
160
- * Get the current track index in the active playlist
161
- * @returns Promise resolving to 0-based index, or -1 if no track playing
160
+ * Set playback speed. Rejects non-finite and non-positive values.
162
161
  */
163
162
  setPlaybackSpeed(speed: number): Promise<void>
164
163
 
@@ -60,6 +60,14 @@ export interface PlayerConfig {
60
60
  androidAutoEnabled?: boolean
61
61
  carPlayEnabled?: boolean
62
62
  showInNotification?: boolean
63
+ /**
64
+ * Fixed interval, in seconds, for remote skip-forward controls (default: 15)
65
+ */
66
+ remoteSkipForwardInterval?: number
67
+ /**
68
+ * Fixed interval, in seconds, for remote skip-backward controls (default: 15)
69
+ */
70
+ remoteSkipBackwardInterval?: number
63
71
  /**
64
72
  * Number of upcoming tracks to preload URLs for (default: 5)
65
73
  * Higher values = more proactive loading, but more network requests
@@ -1,200 +0,0 @@
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
- }