react-native-nitro-player 1.6.0 → 1.7.0-alpha.1
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/README.md +6 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
- package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
- package/android/src/main/res/values/strings.xml +7 -0
- package/ios/HybridDownloadManager.swift +13 -3
- package/ios/HybridTrackPlayer.swift +4 -2
- package/ios/core/TrackPlayerCast.swift +32 -1
- package/ios/core/TrackPlayerCore.swift +19 -3
- package/ios/core/TrackPlayerListener.swift +15 -16
- package/ios/core/TrackPlayerNotify.swift +11 -11
- package/ios/core/TrackPlayerPlayback.swift +54 -16
- package/ios/core/TrackPlayerQueueBuild.swift +14 -2
- package/ios/core/TrackPlayerTempQueue.swift +1 -0
- package/ios/core/TrackPlayerUrlLoader.swift +11 -3
- package/ios/download/DownloadDatabase.swift +16 -5
- package/ios/download/DownloadFileManager.swift +12 -15
- package/ios/download/DownloadManagerCore.swift +61 -16
- package/ios/equalizer/EqualizerCore.swift +37 -9
- package/ios/media/MediaSessionManager.swift +89 -4
- package/ios/playlist/PlaylistManager.swift +47 -46
- package/ios/queue/HybridPlayerQueue.swift +28 -7
- package/lib/hooks/callbackManager.d.ts +9 -0
- package/lib/hooks/callbackManager.js +33 -0
- package/lib/hooks/useActualQueue.js +18 -6
- package/lib/hooks/useAndroidAutoConnection.js +2 -4
- package/lib/hooks/useAudioDevices.js +21 -24
- package/lib/hooks/useDownloadActions.js +38 -16
- package/lib/hooks/useDownloadProgress.js +20 -14
- package/lib/hooks/useDownloadStorage.js +1 -1
- package/lib/hooks/useNowPlaying.js +32 -6
- package/lib/hooks/useOnSeek.js +2 -2
- package/lib/hooks/usePlaylist.js +6 -15
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +44 -0
- package/src/hooks/useActualQueue.ts +18 -6
- package/src/hooks/useAndroidAutoConnection.ts +6 -6
- package/src/hooks/useAudioDevices.ts +21 -24
- package/src/hooks/useDownloadActions.ts +56 -26
- package/src/hooks/useDownloadProgress.ts +22 -19
- package/src/hooks/useDownloadStorage.ts +4 -3
- package/src/hooks/useNowPlaying.ts +34 -6
- package/src/hooks/useOnSeek.ts +2 -2
- package/src/hooks/usePlaylist.ts +6 -14
- package/src/specs/TrackPlayer.nitro.ts +1 -2
- package/src/types/PlayerQueue.ts +8 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { TrackPlayer } from '../index'
|
|
3
|
+
import { callbackManager } from './callbackManager'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Hook to detect Android Auto connection status using the official Android for Cars API
|
|
@@ -15,15 +16,14 @@ export function useAndroidAutoConnection() {
|
|
|
15
16
|
const [isConnected, setIsConnected] = useState<boolean>(false)
|
|
16
17
|
|
|
17
18
|
useEffect(() => {
|
|
18
|
-
// Set initial state
|
|
19
19
|
const initialState = TrackPlayer.isAndroidAutoConnected()
|
|
20
20
|
setIsConnected(initialState)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
return callbackManager.subscribeToAndroidAutoConnection(
|
|
23
|
+
(connected: boolean) => {
|
|
24
|
+
setIsConnected(connected)
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
27
|
}, [])
|
|
28
28
|
|
|
29
29
|
return { isConnected }
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
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((d, i) => d.id === b[i]!.id && d.isActive === b[i]!.isActive)
|
|
8
|
+
}
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* Hook to get audio devices (Android only)
|
|
9
12
|
*
|
|
@@ -30,34 +33,28 @@ export function useAudioDevices() {
|
|
|
30
33
|
const [devices, setDevices] = useState<TAudioDevice[]>([])
|
|
31
34
|
|
|
32
35
|
useEffect(() => {
|
|
33
|
-
if (
|
|
36
|
+
if (!AudioDevices) {
|
|
34
37
|
return undefined
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.error('Error getting audio devices:', error)
|
|
48
|
-
}
|
|
40
|
+
const updateDevices = () => {
|
|
41
|
+
try {
|
|
42
|
+
const currentDevices = AudioDevices!.getAudioDevices()
|
|
43
|
+
// Bail when nothing changed — the bridge returns a fresh array identity
|
|
44
|
+
// every tick, which would otherwise re-render every consumer at 0.5Hz
|
|
45
|
+
setDevices((prev) =>
|
|
46
|
+
devicesEqual(prev, currentDevices) ? prev : currentDevices
|
|
47
|
+
)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Error getting audio devices:', error)
|
|
49
50
|
}
|
|
51
|
+
}
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
updateDevices()
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
const interval = setInterval(updateDevices, 2000)
|
|
55
|
+
const interval = setInterval(updateDevices, 2000)
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
} catch (error) {
|
|
58
|
-
console.error('Error setting up audio devices polling:', error)
|
|
59
|
-
return undefined
|
|
60
|
-
}
|
|
57
|
+
return () => clearInterval(interval)
|
|
61
58
|
}, [])
|
|
62
59
|
|
|
63
60
|
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
|
-
|
|
75
|
+
beginDownloading()
|
|
52
76
|
setError(null)
|
|
53
77
|
try {
|
|
54
78
|
const downloadId = await DownloadManager.downloadTrack(
|
|
@@ -60,15 +84,15 @@ export function useDownloadActions(): UseDownloadActionsResult {
|
|
|
60
84
|
setError(e as Error)
|
|
61
85
|
throw e
|
|
62
86
|
} finally {
|
|
63
|
-
|
|
87
|
+
endDownloading()
|
|
64
88
|
}
|
|
65
89
|
},
|
|
66
|
-
[]
|
|
90
|
+
[beginDownloading, endDownloading]
|
|
67
91
|
)
|
|
68
92
|
|
|
69
93
|
const downloadPlaylist = useCallback(
|
|
70
94
|
async (playlistId: string, tracks: TrackItem[]) => {
|
|
71
|
-
|
|
95
|
+
beginDownloading()
|
|
72
96
|
setError(null)
|
|
73
97
|
try {
|
|
74
98
|
const downloadIds = await DownloadManager.downloadPlaylist(
|
|
@@ -80,10 +104,10 @@ export function useDownloadActions(): UseDownloadActionsResult {
|
|
|
80
104
|
setError(e as Error)
|
|
81
105
|
throw e
|
|
82
106
|
} finally {
|
|
83
|
-
|
|
107
|
+
endDownloading()
|
|
84
108
|
}
|
|
85
109
|
},
|
|
86
|
-
[]
|
|
110
|
+
[beginDownloading, endDownloading]
|
|
87
111
|
)
|
|
88
112
|
|
|
89
113
|
const pauseDownload = useCallback(async (downloadId: string) => {
|
|
@@ -114,32 +138,38 @@ export function useDownloadActions(): UseDownloadActionsResult {
|
|
|
114
138
|
await DownloadManager.cancelAllDownloads()
|
|
115
139
|
}, [])
|
|
116
140
|
|
|
117
|
-
const deleteTrack = useCallback(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
141
|
+
const deleteTrack = useCallback(
|
|
142
|
+
async (trackId: string) => {
|
|
143
|
+
beginDeleting()
|
|
144
|
+
try {
|
|
145
|
+
await DownloadManager.deleteDownloadedTrack(trackId)
|
|
146
|
+
} finally {
|
|
147
|
+
endDeleting()
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[beginDeleting, endDeleting]
|
|
151
|
+
)
|
|
125
152
|
|
|
126
|
-
const deletePlaylist = useCallback(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
const deletePlaylist = useCallback(
|
|
154
|
+
async (playlistId: string) => {
|
|
155
|
+
beginDeleting()
|
|
156
|
+
try {
|
|
157
|
+
await DownloadManager.deleteDownloadedPlaylist(playlistId)
|
|
158
|
+
} finally {
|
|
159
|
+
endDeleting()
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[beginDeleting, endDeleting]
|
|
163
|
+
)
|
|
134
164
|
|
|
135
165
|
const deleteAll = useCallback(async () => {
|
|
136
|
-
|
|
166
|
+
beginDeleting()
|
|
137
167
|
try {
|
|
138
168
|
await DownloadManager.deleteAllDownloads()
|
|
139
169
|
} finally {
|
|
140
|
-
|
|
170
|
+
endDeleting()
|
|
141
171
|
}
|
|
142
|
-
}, [])
|
|
172
|
+
}, [beginDeleting, endDeleting])
|
|
143
173
|
|
|
144
174
|
const configure = useCallback((config: DownloadConfig) => {
|
|
145
175
|
DownloadManager.configure(config)
|
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
[
|
|
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 =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
105
|
+
eventsOwnPosition.current = false
|
|
106
|
+
requestFullState()
|
|
81
107
|
})
|
|
82
|
-
}, [
|
|
108
|
+
}, [requestFullState])
|
|
83
109
|
|
|
84
110
|
// Subscribe to playback state changes — full refresh
|
|
85
111
|
useEffect(() => {
|
|
86
112
|
return callbackManager.subscribeToPlaybackState(() => {
|
|
87
|
-
|
|
113
|
+
requestFullState()
|
|
88
114
|
})
|
|
89
|
-
}, [
|
|
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
|
}, [])
|
package/src/hooks/useOnSeek.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
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
|
-
|
|
18
|
+
return callbackManager.subscribeToSeek((newPosition, newTotalDuration) => {
|
|
19
19
|
setPosition(newPosition)
|
|
20
20
|
setTotalDuration(newTotalDuration)
|
|
21
21
|
})
|
package/src/hooks/usePlaylist.ts
CHANGED
|
@@ -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
|
|
145
|
+
// Subscribe to native playlist changes via the shared manager
|
|
147
146
|
useEffect(() => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
*
|
|
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
|
|
package/src/types/PlayerQueue.ts
CHANGED
|
@@ -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
|
package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt
DELETED
|
@@ -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
|
-
}
|