react-native-nitro-player 1.6.0-alpha.1 → 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 (86) 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/HybridTrackPlayer.kt +6 -0
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +78 -7
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +22 -1
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
  22. package/android/src/main/res/values/strings.xml +7 -0
  23. package/ios/HybridDownloadManager.swift +13 -3
  24. package/ios/HybridTrackPlayer.swift +8 -2
  25. package/ios/core/TrackPlayerCast.swift +32 -1
  26. package/ios/core/TrackPlayerCore.swift +19 -3
  27. package/ios/core/TrackPlayerListener.swift +15 -16
  28. package/ios/core/TrackPlayerNotify.swift +11 -11
  29. package/ios/core/TrackPlayerPlayback.swift +54 -16
  30. package/ios/core/TrackPlayerQueueBuild.swift +14 -2
  31. package/ios/core/TrackPlayerTempQueue.swift +1 -0
  32. package/ios/core/TrackPlayerUrlLoader.swift +11 -3
  33. package/ios/download/DownloadDatabase.swift +16 -5
  34. package/ios/download/DownloadFileManager.swift +12 -15
  35. package/ios/download/DownloadManagerCore.swift +61 -16
  36. package/ios/equalizer/EqualizerCore.swift +37 -9
  37. package/ios/media/MediaSessionManager.swift +89 -4
  38. package/ios/playlist/PlaylistManager.swift +47 -46
  39. package/ios/queue/HybridPlayerQueue.swift +28 -7
  40. package/lib/hooks/callbackManager.d.ts +9 -0
  41. package/lib/hooks/callbackManager.js +33 -0
  42. package/lib/hooks/useActualQueue.js +18 -6
  43. package/lib/hooks/useAndroidAutoConnection.js +2 -4
  44. package/lib/hooks/useAudioDevices.js +21 -24
  45. package/lib/hooks/useDownloadActions.js +33 -11
  46. package/lib/hooks/useDownloadProgress.js +20 -14
  47. package/lib/hooks/useDownloadStorage.js +1 -1
  48. package/lib/hooks/useNowPlaying.js +32 -6
  49. package/lib/hooks/useOnSeek.js +2 -2
  50. package/lib/hooks/usePlaylist.js +6 -15
  51. package/lib/specs/TrackPlayer.nitro.d.ts +6 -2
  52. package/lib/types/PlayerQueue.d.ts +8 -0
  53. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  54. package/nitrogen/generated/android/c++/JFunc_void_TrackItem.hpp +85 -0
  55. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  56. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  57. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackItem.kt +80 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
  61. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  62. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +22 -0
  63. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -0
  64. package/nitrogen/generated/ios/swift/Func_void_TrackItem.swift +46 -0
  65. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  66. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  67. package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
  68. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  69. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  70. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
  71. package/package.json +1 -1
  72. package/src/hooks/callbackManager.ts +44 -0
  73. package/src/hooks/useActualQueue.ts +18 -6
  74. package/src/hooks/useAndroidAutoConnection.ts +6 -6
  75. package/src/hooks/useAudioDevices.ts +23 -24
  76. package/src/hooks/useDownloadActions.ts +35 -11
  77. package/src/hooks/useDownloadProgress.ts +22 -19
  78. package/src/hooks/useDownloadStorage.ts +4 -3
  79. package/src/hooks/useNowPlaying.ts +34 -6
  80. package/src/hooks/useOnSeek.ts +2 -2
  81. package/src/hooks/usePlaylist.ts +6 -14
  82. package/src/specs/TrackPlayer.nitro.ts +6 -2
  83. package/src/types/PlayerQueue.ts +8 -0
  84. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
  85. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
  86. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
@@ -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.
@@ -102,6 +102,11 @@ export interface TrackPlayer extends HybridObject<{
102
102
  * updates and ID3 frames emitted while a live stream plays.
103
103
  */
104
104
  onTimedMetadata(callback: (metadata: TimedMetadata) => void): void
105
+ /**
106
+ * Fires when the app was opened by tapping the playback notification,
107
+ * with the track that was playing at tap time. Android only — no-op on iOS.
108
+ */
109
+ appStartedWithNotification(callback: (track: TrackItem) => void): void
105
110
  onAndroidAutoConnectionChange(callback: (connected: boolean) => void): void
106
111
  isAndroidAutoConnected(): boolean
107
112
  setVolume(volume: number): Promise<void>
@@ -152,8 +157,7 @@ export interface TrackPlayer extends HybridObject<{
152
157
  ): void
153
158
 
154
159
  /**
155
- * Get the current track index in the active playlist
156
- * @returns Promise resolving to 0-based index, or -1 if no track playing
160
+ * Set playback speed. Rejects non-finite and non-positive values.
157
161
  */
158
162
  setPlaybackSpeed(speed: number): Promise<void>
159
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
- }
@@ -1,94 +0,0 @@
1
- package com.margelo.nitro.nitroplayer.queue
2
-
3
- import com.margelo.nitro.nitroplayer.TrackItem
4
- import java.util.concurrent.CopyOnWriteArrayList
5
-
6
- /**
7
- * Queue class that manages a list of tracks.
8
- * Thread-safe implementation using CopyOnWriteArrayList.
9
- */
10
- class Queue {
11
- private val tracks: MutableList<TrackItem> = CopyOnWriteArrayList()
12
-
13
- /**
14
- * Get all tracks in the queue
15
- */
16
- fun getTracks(): List<TrackItem> = ArrayList(tracks)
17
-
18
- /**
19
- * Get tracks as an array (for compatibility with existing API)
20
- */
21
- fun getTracksArray(): Array<TrackItem> = tracks.toTypedArray()
22
-
23
- /**
24
- * Add a single track to the queue
25
- */
26
- fun addTrack(track: TrackItem) {
27
- tracks.add(track)
28
- }
29
-
30
- /**
31
- * Add a track at a specific index
32
- */
33
- fun addTrackAtIndex(
34
- track: TrackItem,
35
- index: Int,
36
- ) {
37
- if (index < 0 || index > tracks.size) {
38
- tracks.add(track)
39
- } else {
40
- tracks.add(index, track)
41
- }
42
- }
43
-
44
- /**
45
- * Load multiple tracks into the queue (replaces existing queue)
46
- */
47
- fun loadTracks(newTracks: List<TrackItem>) {
48
- tracks.clear()
49
- tracks.addAll(newTracks)
50
- }
51
-
52
- /**
53
- * Remove a track by ID
54
- * @return true if track was found and removed, false otherwise
55
- */
56
- fun removeTrack(id: String): Boolean = tracks.removeAll { it.id == id }
57
-
58
- /**
59
- * Clear all tracks from the queue
60
- */
61
- fun clear() {
62
- tracks.clear()
63
- }
64
-
65
- /**
66
- * Get the size of the queue
67
- */
68
- fun size(): Int = tracks.size
69
-
70
- /**
71
- * Check if the queue is empty
72
- */
73
- fun isEmpty(): Boolean = tracks.isEmpty()
74
-
75
- /**
76
- * Get a track by index
77
- */
78
- fun getTrack(index: Int): TrackItem? =
79
- if (index >= 0 && index < tracks.size) {
80
- tracks[index]
81
- } else {
82
- null
83
- }
84
-
85
- /**
86
- * Get a track by ID
87
- */
88
- fun getTrackById(id: String): TrackItem? = tracks.find { it.id == id }
89
-
90
- /**
91
- * Get the index of a track by ID
92
- */
93
- fun getTrackIndex(id: String): Int = tracks.indexOfFirst { it.id == id }
94
- }