react-native-nitro-player 0.3.0-alpha.6 → 0.3.0-alpha.7
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 +699 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +44 -7
- package/ios/HybridTrackPlayer.swift +6 -0
- package/ios/core/TrackPlayerCore.swift +214 -41
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +4 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
- package/package.json +8 -4
- package/src/specs/TrackPlayer.nitro.ts +1 -0
package/README.md
CHANGED
|
@@ -1 +1,699 @@
|
|
|
1
|
-
|
|
1
|
+
# React Native Nitro Player
|
|
2
|
+
|
|
3
|
+
A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install react-native-nitro-player
|
|
9
|
+
# or
|
|
10
|
+
yarn add react-native-nitro-player
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Peer Dependencies
|
|
14
|
+
|
|
15
|
+
Make sure you have these installed:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install react-native-nitro-modules
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Configure the Player
|
|
24
|
+
|
|
25
|
+
Configure the player before using it in your app:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { TrackPlayer } from 'react-native-nitro-player'
|
|
29
|
+
|
|
30
|
+
TrackPlayer.configure({
|
|
31
|
+
androidAutoEnabled: true,
|
|
32
|
+
carPlayEnabled: false,
|
|
33
|
+
showInNotification: true,
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Create Playlists
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { PlayerQueue } from 'react-native-nitro-player'
|
|
41
|
+
import type { TrackItem } from 'react-native-nitro-player'
|
|
42
|
+
|
|
43
|
+
const tracks: TrackItem[] = [
|
|
44
|
+
{
|
|
45
|
+
id: '1',
|
|
46
|
+
title: 'Song Title',
|
|
47
|
+
artist: 'Artist Name',
|
|
48
|
+
album: 'Album Name',
|
|
49
|
+
duration: 180.0, // in seconds
|
|
50
|
+
url: 'https://example.com/song.mp3',
|
|
51
|
+
artwork: 'https://example.com/artwork.jpg',
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
// Create a playlist
|
|
56
|
+
const playlistId = PlayerQueue.createPlaylist(
|
|
57
|
+
'My Playlist',
|
|
58
|
+
'Playlist description',
|
|
59
|
+
'https://example.com/playlist-artwork.jpg'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// Add tracks to the playlist
|
|
63
|
+
PlayerQueue.addTracksToPlaylist(playlistId, tracks)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Play Music
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { TrackPlayer, PlayerQueue } from 'react-native-nitro-player'
|
|
70
|
+
|
|
71
|
+
// Load and play a playlist
|
|
72
|
+
PlayerQueue.loadPlaylist(playlistId)
|
|
73
|
+
|
|
74
|
+
// Or play a specific song
|
|
75
|
+
TrackPlayer.playSong('song-id', playlistId)
|
|
76
|
+
|
|
77
|
+
// Basic controls
|
|
78
|
+
TrackPlayer.play()
|
|
79
|
+
TrackPlayer.pause()
|
|
80
|
+
TrackPlayer.skipToNext()
|
|
81
|
+
TrackPlayer.skipToPrevious()
|
|
82
|
+
TrackPlayer.seek(30) // Seek to 30 seconds
|
|
83
|
+
|
|
84
|
+
// Set repeat mode
|
|
85
|
+
TrackPlayer.setRepeatMode('off') // No repeat
|
|
86
|
+
TrackPlayer.setRepeatMode('Playlist') // Repeat entire playlist
|
|
87
|
+
TrackPlayer.setRepeatMode('track') // Repeat current track
|
|
88
|
+
|
|
89
|
+
// Set volume (0-100)
|
|
90
|
+
TrackPlayer.setVolume(50) // Set volume to 50%
|
|
91
|
+
TrackPlayer.setVolume(0) // Mute
|
|
92
|
+
TrackPlayer.setVolume(100) // Maximum volume
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Core Concepts
|
|
96
|
+
|
|
97
|
+
### PlayerQueue
|
|
98
|
+
|
|
99
|
+
Manages playlists and tracks. Use it to:
|
|
100
|
+
|
|
101
|
+
- Create, update, and delete playlists
|
|
102
|
+
- Add or remove tracks from playlists
|
|
103
|
+
- Load playlists for playback
|
|
104
|
+
- Listen to playlist changes
|
|
105
|
+
|
|
106
|
+
### TrackPlayer
|
|
107
|
+
|
|
108
|
+
Controls playback. Use it to:
|
|
109
|
+
|
|
110
|
+
- Play, pause, and seek
|
|
111
|
+
- Skip tracks
|
|
112
|
+
- Control repeat mode
|
|
113
|
+
- Control volume
|
|
114
|
+
- Get current player state
|
|
115
|
+
- Listen to playback events
|
|
116
|
+
|
|
117
|
+
## React Hooks
|
|
118
|
+
|
|
119
|
+
The library provides React hooks for reactive state management. These hooks automatically update your components when player state changes.
|
|
120
|
+
|
|
121
|
+
### `useOnChangeTrack()`
|
|
122
|
+
|
|
123
|
+
Returns the current track and the reason why it changed.
|
|
124
|
+
|
|
125
|
+
**Returns:**
|
|
126
|
+
|
|
127
|
+
- `track: TrackItem | undefined` - The current track, or `undefined` if no track is playing
|
|
128
|
+
- `reason: Reason | undefined` - The reason for the track change (`'user_action'`, `'skip'`, `'end'`, or `'error'`)
|
|
129
|
+
|
|
130
|
+
### `useOnPlaybackStateChange()`
|
|
131
|
+
|
|
132
|
+
Returns the current playback state and the reason for the state change.
|
|
133
|
+
|
|
134
|
+
**Returns:**
|
|
135
|
+
|
|
136
|
+
- `state: TrackPlayerState | undefined` - Current playback state (`'playing'`, `'paused'`, or `'stopped'`)
|
|
137
|
+
- `reason: Reason | undefined` - The reason for the state change
|
|
138
|
+
|
|
139
|
+
### `useOnPlaybackProgressChange()`
|
|
140
|
+
|
|
141
|
+
Returns real-time playback progress updates.
|
|
142
|
+
|
|
143
|
+
**Returns:**
|
|
144
|
+
|
|
145
|
+
- `position: number` - Current playback position in seconds
|
|
146
|
+
- `totalDuration: number` - Total duration of the current track in seconds
|
|
147
|
+
- `isManuallySeeked: boolean | undefined` - `true` if the user manually seeked, `undefined` otherwise
|
|
148
|
+
|
|
149
|
+
### `useOnSeek()`
|
|
150
|
+
|
|
151
|
+
Returns information about the last seek event.
|
|
152
|
+
|
|
153
|
+
**Returns:**
|
|
154
|
+
|
|
155
|
+
- `position: number | undefined` - The position where the user seeked to, or `undefined` if no seek has occurred
|
|
156
|
+
- `totalDuration: number | undefined` - The total duration at the time of seek, or `undefined` if no seek has occurred
|
|
157
|
+
|
|
158
|
+
### `useAndroidAutoConnection()`
|
|
159
|
+
|
|
160
|
+
Monitors Android Auto connection status.
|
|
161
|
+
|
|
162
|
+
**Returns:**
|
|
163
|
+
|
|
164
|
+
- `isConnected: boolean` - `true` if connected to Android Auto, `false` otherwise
|
|
165
|
+
|
|
166
|
+
### `useAudioDevices()` (Android only)
|
|
167
|
+
|
|
168
|
+
Automatically polls for audio device changes every 2 seconds.
|
|
169
|
+
|
|
170
|
+
**Returns:**
|
|
171
|
+
|
|
172
|
+
- `devices: TAudioDevice[]` - Array of available audio devices
|
|
173
|
+
|
|
174
|
+
## Audio Device APIs
|
|
175
|
+
|
|
176
|
+
### `AudioDevices` (Android only)
|
|
177
|
+
|
|
178
|
+
Android-specific API for managing audio output devices.
|
|
179
|
+
|
|
180
|
+
#### `getAudioDevices(): TAudioDevice[]`
|
|
181
|
+
|
|
182
|
+
Returns the list of available audio output devices.
|
|
183
|
+
|
|
184
|
+
**Returns:** Array of `TAudioDevice` objects with:
|
|
185
|
+
|
|
186
|
+
- `id: number` - Unique device ID
|
|
187
|
+
- `name: string` - Device name (e.g., "Built-in Speaker", "Bluetooth")
|
|
188
|
+
- `type: number` - Device type constant
|
|
189
|
+
- `isActive: boolean` - Whether this device is currently active
|
|
190
|
+
|
|
191
|
+
**Example:**
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { AudioDevices } from 'react-native-nitro-player'
|
|
195
|
+
|
|
196
|
+
if (AudioDevices) {
|
|
197
|
+
const devices = AudioDevices.getAudioDevices()
|
|
198
|
+
devices.forEach(device => {
|
|
199
|
+
console.log(`${device.name} - Active: ${device.isActive}`)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `setAudioDevice(deviceId: number): boolean`
|
|
205
|
+
|
|
206
|
+
Sets the active audio output device.
|
|
207
|
+
|
|
208
|
+
**Parameters:**
|
|
209
|
+
|
|
210
|
+
- `deviceId: number` - The ID of the device to activate
|
|
211
|
+
|
|
212
|
+
**Returns:** `true` if successful, `false` otherwise
|
|
213
|
+
|
|
214
|
+
**Example:**
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { AudioDevices } from 'react-native-nitro-player'
|
|
218
|
+
|
|
219
|
+
if (AudioDevices) {
|
|
220
|
+
const success = AudioDevices.setAudioDevice(deviceId)
|
|
221
|
+
console.log(`Device switch: ${success ? 'success' : 'failed'}`)
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `AudioRoutePicker` (iOS only)
|
|
226
|
+
|
|
227
|
+
iOS-specific API for displaying the native audio route picker (AirPlay menu).
|
|
228
|
+
|
|
229
|
+
#### `showRoutePicker(): void`
|
|
230
|
+
|
|
231
|
+
Shows the native AVRoutePickerView for selecting audio output routes like AirPlay, Bluetooth, etc.
|
|
232
|
+
|
|
233
|
+
**Example:**
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { AudioRoutePicker } from 'react-native-nitro-player'
|
|
237
|
+
|
|
238
|
+
if (AudioRoutePicker) {
|
|
239
|
+
AudioRoutePicker.showRoutePicker()
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Repeat Mode
|
|
244
|
+
|
|
245
|
+
Control how tracks repeat during playback.
|
|
246
|
+
|
|
247
|
+
### `setRepeatMode(mode: RepeatMode): boolean`
|
|
248
|
+
|
|
249
|
+
Sets the repeat mode for the player.
|
|
250
|
+
|
|
251
|
+
**Parameters:**
|
|
252
|
+
|
|
253
|
+
- `mode: 'off' | 'Playlist' | 'track'` - The repeat mode to set
|
|
254
|
+
- `'off'` - No repeat, playlist stops at the end
|
|
255
|
+
- `'Playlist'` - Repeat the entire playlist
|
|
256
|
+
- `'track'` - Repeat the current track only
|
|
257
|
+
|
|
258
|
+
**Returns:** `true` if successful, `false` otherwise
|
|
259
|
+
|
|
260
|
+
**Example:**
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
import { TrackPlayer } from 'react-native-nitro-player'
|
|
264
|
+
|
|
265
|
+
// Turn off repeat
|
|
266
|
+
TrackPlayer.setRepeatMode('off')
|
|
267
|
+
|
|
268
|
+
// Repeat entire playlist
|
|
269
|
+
TrackPlayer.setRepeatMode('Playlist')
|
|
270
|
+
|
|
271
|
+
// Repeat current track
|
|
272
|
+
TrackPlayer.setRepeatMode('track')
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Volume Control
|
|
276
|
+
|
|
277
|
+
Control the playback volume level.
|
|
278
|
+
|
|
279
|
+
### `setVolume(volume: number): boolean`
|
|
280
|
+
|
|
281
|
+
Sets the playback volume level.
|
|
282
|
+
|
|
283
|
+
**Parameters:**
|
|
284
|
+
|
|
285
|
+
- `volume: number` - Volume level between 0 and 100
|
|
286
|
+
- `0` - Mute (no sound)
|
|
287
|
+
- `50` - Half volume
|
|
288
|
+
- `100` - Maximum volume
|
|
289
|
+
|
|
290
|
+
**Returns:** `true` if successful, `false` otherwise (e.g., if player is not initialized)
|
|
291
|
+
|
|
292
|
+
**Example:**
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { TrackPlayer } from 'react-native-nitro-player'
|
|
296
|
+
|
|
297
|
+
// Set volume to 50%
|
|
298
|
+
const success = TrackPlayer.setVolume(50)
|
|
299
|
+
if (success) {
|
|
300
|
+
console.log('Volume set successfully')
|
|
301
|
+
} else {
|
|
302
|
+
console.warn('Failed to set volume')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Mute the player
|
|
306
|
+
TrackPlayer.setVolume(0)
|
|
307
|
+
|
|
308
|
+
// Set to maximum volume
|
|
309
|
+
TrackPlayer.setVolume(100)
|
|
310
|
+
|
|
311
|
+
// Incremental volume control
|
|
312
|
+
const currentVolume = 50
|
|
313
|
+
TrackPlayer.setVolume(currentVolume + 10) // Increase by 10%
|
|
314
|
+
TrackPlayer.setVolume(currentVolume - 10) // Decrease by 10%
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**Note:** The volume value is automatically clamped to the 0-100 range. Values outside this range will be clamped to the nearest valid value.
|
|
318
|
+
|
|
319
|
+
## Usage Examples
|
|
320
|
+
|
|
321
|
+
### Using React Hooks
|
|
322
|
+
|
|
323
|
+
The library provides convenient React hooks for reactive state management:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import {
|
|
327
|
+
useOnChangeTrack,
|
|
328
|
+
useOnPlaybackStateChange,
|
|
329
|
+
useOnPlaybackProgressChange,
|
|
330
|
+
useOnSeek,
|
|
331
|
+
useAndroidAutoConnection,
|
|
332
|
+
} from 'react-native-nitro-player'
|
|
333
|
+
|
|
334
|
+
function PlayerComponent() {
|
|
335
|
+
// Get current track
|
|
336
|
+
const { track, reason } = useOnChangeTrack()
|
|
337
|
+
|
|
338
|
+
// Get playback state (playing, paused, stopped)
|
|
339
|
+
const { state, reason: stateReason } = useOnPlaybackStateChange()
|
|
340
|
+
|
|
341
|
+
// Get playback progress
|
|
342
|
+
const { position, totalDuration, isManuallySeeked } = useOnPlaybackProgressChange()
|
|
343
|
+
|
|
344
|
+
// Get seek events
|
|
345
|
+
const { position: seekPosition, totalDuration: seekDuration } = useOnSeek()
|
|
346
|
+
|
|
347
|
+
// Check Android Auto connection
|
|
348
|
+
const { isConnected } = useAndroidAutoConnection()
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<View>
|
|
352
|
+
{track && (
|
|
353
|
+
<Text>Now Playing: {track.title} by {track.artist}</Text>
|
|
354
|
+
)}
|
|
355
|
+
<Text>State: {state}</Text>
|
|
356
|
+
<Text>Progress: {position} / {totalDuration}</Text>
|
|
357
|
+
</View>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Managing Playlists
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { PlayerQueue } from 'react-native-nitro-player'
|
|
366
|
+
import type { TrackItem, Playlist } from 'react-native-nitro-player'
|
|
367
|
+
|
|
368
|
+
// Get all playlists
|
|
369
|
+
const playlists = PlayerQueue.getAllPlaylists()
|
|
370
|
+
|
|
371
|
+
// Get a specific playlist
|
|
372
|
+
const playlist = PlayerQueue.getPlaylist(playlistId)
|
|
373
|
+
|
|
374
|
+
// Get current playing playlist
|
|
375
|
+
const currentPlaylistId = PlayerQueue.getCurrentPlaylistId()
|
|
376
|
+
|
|
377
|
+
// Update playlist metadata
|
|
378
|
+
PlayerQueue.updatePlaylist(playlistId, {
|
|
379
|
+
name: 'Updated Name',
|
|
380
|
+
description: 'New description',
|
|
381
|
+
artwork: 'https://example.com/new-artwork.jpg',
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Add a single track
|
|
385
|
+
PlayerQueue.addTrackToPlaylist(playlistId, newTrack)
|
|
386
|
+
|
|
387
|
+
// Add multiple tracks
|
|
388
|
+
PlayerQueue.addTracksToPlaylist(playlistId, [track1, track2, track3])
|
|
389
|
+
|
|
390
|
+
// Remove a track
|
|
391
|
+
PlayerQueue.removeTrackFromPlaylist(playlistId, trackId)
|
|
392
|
+
|
|
393
|
+
// Reorder tracks
|
|
394
|
+
PlayerQueue.reorderTrackInPlaylist(playlistId, trackId, newIndex)
|
|
395
|
+
|
|
396
|
+
// Delete a playlist
|
|
397
|
+
PlayerQueue.deletePlaylist(playlistId)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Listening to Events
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
import { PlayerQueue, TrackPlayer } from 'react-native-nitro-player'
|
|
404
|
+
|
|
405
|
+
// Listen to playlist changes
|
|
406
|
+
PlayerQueue.onPlaylistsChanged((playlists, operation) => {
|
|
407
|
+
console.log('Playlists updated:', operation)
|
|
408
|
+
// operation can be: 'add', 'remove', 'clear', 'update'
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// Listen to specific playlist changes
|
|
412
|
+
PlayerQueue.onPlaylistChanged((playlistId, playlist, operation) => {
|
|
413
|
+
console.log('Playlist changed:', playlistId, operation)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Listen to track changes
|
|
417
|
+
TrackPlayer.onChangeTrack((track, reason) => {
|
|
418
|
+
console.log('Track changed:', track.title, reason)
|
|
419
|
+
// reason can be: 'user_action', 'skip', 'end', 'error'
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// Listen to playback state changes
|
|
423
|
+
TrackPlayer.onPlaybackStateChange((state, reason) => {
|
|
424
|
+
console.log('State changed:', state, reason)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// Listen to seek events
|
|
428
|
+
TrackPlayer.onSeek((position, totalDuration) => {
|
|
429
|
+
console.log('Seeked to:', position)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// Listen to playback progress
|
|
433
|
+
TrackPlayer.onPlaybackProgressChange(
|
|
434
|
+
(position, totalDuration, isManuallySeeked) => {
|
|
435
|
+
console.log('Progress:', position, '/', totalDuration)
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// Listen to Android Auto connection changes
|
|
440
|
+
TrackPlayer.onAndroidAutoConnectionChange(connected => {
|
|
441
|
+
console.log('Android Auto:', connected ? 'Connected' : 'Disconnected')
|
|
442
|
+
})
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Getting Player State
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { TrackPlayer } from 'react-native-nitro-player'
|
|
449
|
+
|
|
450
|
+
const state = TrackPlayer.getState()
|
|
451
|
+
|
|
452
|
+
console.log(state.currentState) // 'playing' | 'paused' | 'stopped'
|
|
453
|
+
console.log(state.currentPosition) // current position in seconds
|
|
454
|
+
console.log(state.totalDuration) // total duration in seconds
|
|
455
|
+
console.log(state.currentTrack) // current TrackItem or null
|
|
456
|
+
console.log(state.currentPlaylistId) // current playlist ID or null
|
|
457
|
+
console.log(state.currentIndex) // current track index in playlist
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Track Item Structure
|
|
461
|
+
|
|
462
|
+
Each track must follow this structure:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
interface TrackItem {
|
|
466
|
+
id: string // Unique identifier
|
|
467
|
+
title: string // Track title
|
|
468
|
+
artist: string // Artist name
|
|
469
|
+
album: string // Album name
|
|
470
|
+
duration: number // Duration in seconds
|
|
471
|
+
url: string // Audio file URL
|
|
472
|
+
artwork?: string | null // Optional artwork URL
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## Playlist Structure
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
interface Playlist {
|
|
480
|
+
id: string // Unique identifier
|
|
481
|
+
name: string // Playlist name
|
|
482
|
+
description?: string | null // Optional description
|
|
483
|
+
artwork?: string | null // Optional artwork URL
|
|
484
|
+
tracks: TrackItem[] // Array of tracks
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## Android Auto Customization
|
|
489
|
+
|
|
490
|
+
Customize how your music library appears in Android Auto with a custom folder structure.
|
|
491
|
+
|
|
492
|
+
### Basic Setup
|
|
493
|
+
|
|
494
|
+
By default, all playlists are shown in Android Auto. You can create a custom structure:
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
import { AndroidAutoMediaLibraryHelper } from 'react-native-nitro-player'
|
|
498
|
+
import type { MediaLibrary } from 'react-native-nitro-player'
|
|
499
|
+
|
|
500
|
+
// Check if available (Android only)
|
|
501
|
+
if (AndroidAutoMediaLibraryHelper.isAvailable()) {
|
|
502
|
+
const mediaLibrary: MediaLibrary = {
|
|
503
|
+
layoutType: 'grid', // 'grid' or 'list'
|
|
504
|
+
rootItems: [
|
|
505
|
+
{
|
|
506
|
+
id: 'my_music',
|
|
507
|
+
title: '🎵 My Music',
|
|
508
|
+
subtitle: 'Your music collection',
|
|
509
|
+
mediaType: 'folder',
|
|
510
|
+
isPlayable: false,
|
|
511
|
+
layoutType: 'grid',
|
|
512
|
+
children: [
|
|
513
|
+
{
|
|
514
|
+
id: 'favorites',
|
|
515
|
+
title: 'Favorites',
|
|
516
|
+
subtitle: '10 tracks',
|
|
517
|
+
mediaType: 'playlist',
|
|
518
|
+
playlistId: 'my-playlist-id', // References a playlist created with PlayerQueue
|
|
519
|
+
isPlayable: false,
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: 'recent',
|
|
525
|
+
title: '🕐 Recently Played',
|
|
526
|
+
mediaType: 'folder',
|
|
527
|
+
isPlayable: false,
|
|
528
|
+
children: [
|
|
529
|
+
// More playlist references...
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
AndroidAutoMediaLibraryHelper.set(mediaLibrary)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Reset to default (show all playlists)
|
|
539
|
+
AndroidAutoMediaLibraryHelper.clear()
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### MediaLibrary Structure
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
interface MediaLibrary {
|
|
546
|
+
layoutType: 'grid' | 'list' // Default layout for items
|
|
547
|
+
rootItems: MediaItem[] // Top-level items
|
|
548
|
+
appName?: string // Optional app name
|
|
549
|
+
appIconUrl?: string // Optional app icon
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
interface MediaItem {
|
|
553
|
+
id: string // Unique identifier
|
|
554
|
+
title: string // Display title
|
|
555
|
+
subtitle?: string // Optional subtitle
|
|
556
|
+
iconUrl?: string // Optional icon/artwork URL
|
|
557
|
+
isPlayable: boolean // Whether item can be played
|
|
558
|
+
mediaType: 'folder' | 'audio' | 'playlist' // Type of item
|
|
559
|
+
playlistId?: string // Reference to playlist (for playlist items)
|
|
560
|
+
children?: MediaItem[] // Child items (for folders)
|
|
561
|
+
layoutType?: 'grid' | 'list' // Override default layout
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Example: Organizing Playlists by Genre
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
import {
|
|
569
|
+
PlayerQueue,
|
|
570
|
+
AndroidAutoMediaLibraryHelper,
|
|
571
|
+
} from 'react-native-nitro-player'
|
|
572
|
+
|
|
573
|
+
// Create playlists first
|
|
574
|
+
const rockPlaylistId = PlayerQueue.createPlaylist('Rock Classics')
|
|
575
|
+
const jazzPlaylistId = PlayerQueue.createPlaylist('Jazz Essentials')
|
|
576
|
+
const popPlaylistId = PlayerQueue.createPlaylist('Pop Hits')
|
|
577
|
+
|
|
578
|
+
// Add tracks to playlists...
|
|
579
|
+
PlayerQueue.addTracksToPlaylist(rockPlaylistId, rockTracks)
|
|
580
|
+
PlayerQueue.addTracksToPlaylist(jazzPlaylistId, jazzTracks)
|
|
581
|
+
PlayerQueue.addTracksToPlaylist(popPlaylistId, popTracks)
|
|
582
|
+
|
|
583
|
+
// Create custom Android Auto structure
|
|
584
|
+
AndroidAutoMediaLibraryHelper.set({
|
|
585
|
+
layoutType: 'list',
|
|
586
|
+
rootItems: [
|
|
587
|
+
{
|
|
588
|
+
id: 'genres',
|
|
589
|
+
title: '🎸 By Genre',
|
|
590
|
+
mediaType: 'folder',
|
|
591
|
+
isPlayable: false,
|
|
592
|
+
layoutType: 'grid',
|
|
593
|
+
children: [
|
|
594
|
+
{
|
|
595
|
+
id: 'rock',
|
|
596
|
+
title: 'Rock',
|
|
597
|
+
mediaType: 'playlist',
|
|
598
|
+
playlistId: rockPlaylistId,
|
|
599
|
+
isPlayable: false,
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
id: 'jazz',
|
|
603
|
+
title: 'Jazz',
|
|
604
|
+
mediaType: 'playlist',
|
|
605
|
+
playlistId: jazzPlaylistId,
|
|
606
|
+
isPlayable: false,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: 'pop',
|
|
610
|
+
title: 'Pop',
|
|
611
|
+
mediaType: 'playlist',
|
|
612
|
+
playlistId: popPlaylistId,
|
|
613
|
+
isPlayable: false,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
id: 'all_music',
|
|
619
|
+
title: '📀 All Music',
|
|
620
|
+
mediaType: 'folder',
|
|
621
|
+
isPlayable: false,
|
|
622
|
+
children: [
|
|
623
|
+
{
|
|
624
|
+
id: 'all_rock',
|
|
625
|
+
title: 'Rock Classics',
|
|
626
|
+
mediaType: 'playlist',
|
|
627
|
+
playlistId: rockPlaylistId,
|
|
628
|
+
isPlayable: false,
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: 'all_jazz',
|
|
632
|
+
title: 'Jazz Essentials',
|
|
633
|
+
mediaType: 'playlist',
|
|
634
|
+
playlistId: jazzPlaylistId,
|
|
635
|
+
isPlayable: false,
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: 'all_pop',
|
|
639
|
+
title: 'Pop Hits',
|
|
640
|
+
mediaType: 'playlist',
|
|
641
|
+
playlistId: popPlaylistId,
|
|
642
|
+
isPlayable: false,
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
})
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### Notes
|
|
651
|
+
|
|
652
|
+
- The `playlistId` field must reference a playlist created with `PlayerQueue.createPlaylist()`
|
|
653
|
+
- Changes are immediately reflected in Android Auto
|
|
654
|
+
- Use folders to organize playlists hierarchically
|
|
655
|
+
- Grid layout is best for album/playlist browsing
|
|
656
|
+
- List layout is best for song lists
|
|
657
|
+
- Only available on Android (use `isAvailable()` to check)
|
|
658
|
+
|
|
659
|
+
## Features
|
|
660
|
+
|
|
661
|
+
- ✅ **Playlist Management**: Create, update, and manage multiple playlists
|
|
662
|
+
- ✅ **Playback Controls**: Play, pause, seek, skip tracks
|
|
663
|
+
- ✅ **Volume Control**: Adjust playback volume (0-100)
|
|
664
|
+
- ✅ **React Hooks**: Built-in hooks for reactive state management
|
|
665
|
+
- ✅ **Event Listeners**: Listen to track changes, state changes, and more
|
|
666
|
+
- ✅ **Android Auto Support**: Control playback from Android Auto with customizable UI
|
|
667
|
+
- ✅ **CarPlay Support**: Control playback from CarPlay (iOS)
|
|
668
|
+
- ✅ **Notification Controls**: Show playback controls in notifications
|
|
669
|
+
- ✅ **Progress Tracking**: Real-time playback progress updates
|
|
670
|
+
|
|
671
|
+
## TypeScript Support
|
|
672
|
+
|
|
673
|
+
The library is written in TypeScript and includes full type definitions. All types are exported for your convenience:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import type {
|
|
677
|
+
TrackItem,
|
|
678
|
+
Playlist,
|
|
679
|
+
PlayerState,
|
|
680
|
+
TrackPlayerState,
|
|
681
|
+
QueueOperation,
|
|
682
|
+
Reason,
|
|
683
|
+
PlayerConfig,
|
|
684
|
+
MediaLibrary,
|
|
685
|
+
MediaItem,
|
|
686
|
+
LayoutType,
|
|
687
|
+
MediaType,
|
|
688
|
+
} from 'react-native-nitro-player'
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
## Platform Support
|
|
692
|
+
|
|
693
|
+
- ✅ **iOS**: Full support with CarPlay integration
|
|
694
|
+
- ✅ **Android**: Full support with Android Auto integration
|
|
695
|
+
- 🎯 **Android Auto Media Library**: Android-only feature for customizing the Android Auto UI
|
|
696
|
+
|
|
697
|
+
## License
|
|
698
|
+
|
|
699
|
+
MIT
|
|
@@ -4,6 +4,8 @@ package com.margelo.nitro.nitroplayer.core
|
|
|
4
4
|
|
|
5
5
|
import android.content.Context
|
|
6
6
|
import android.net.Uri
|
|
7
|
+
import androidx.media3.common.AudioAttributes
|
|
8
|
+
import androidx.media3.common.C
|
|
7
9
|
import androidx.media3.common.MediaItem
|
|
8
10
|
import androidx.media3.common.MediaMetadata
|
|
9
11
|
import androidx.media3.common.Player
|
|
@@ -72,25 +74,43 @@ class TrackPlayerCore private constructor(
|
|
|
72
74
|
|
|
73
75
|
init {
|
|
74
76
|
handler.post {
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
+
// ============================================================
|
|
78
|
+
// GAPLESS PLAYBACK CONFIGURATION
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Configure LoadControl for maximum gapless playback
|
|
81
|
+
// Large buffers ensure next track is fully ready before current ends
|
|
77
82
|
val loadControl =
|
|
78
83
|
DefaultLoadControl
|
|
79
84
|
.Builder()
|
|
80
85
|
.setBufferDurationsMs(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
).setBackBuffer(
|
|
86
|
+
30_000, // MIN_BUFFER_MS: 30 seconds minimum buffer
|
|
87
|
+
120_000, // MAX_BUFFER_MS: 2 minutes maximum buffer (enables preloading next tracks)
|
|
88
|
+
2_500, // BUFFER_FOR_PLAYBACK_MS: 2.5s before playback starts
|
|
89
|
+
5_000, // BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS: 5s after rebuffer
|
|
90
|
+
).setBackBuffer(30_000, true) // Keep 30s back buffer for seamless seek-back
|
|
91
|
+
.setTargetBufferBytes(C.LENGTH_UNSET) // No size limit - prioritize time
|
|
86
92
|
.setPrioritizeTimeOverSizeThresholds(true) // Prioritize time-based buffering
|
|
87
93
|
.build()
|
|
88
94
|
|
|
95
|
+
// Configure audio attributes for optimal music playback
|
|
96
|
+
// This enables gapless audio processing in the audio pipeline
|
|
97
|
+
val audioAttributes =
|
|
98
|
+
AudioAttributes
|
|
99
|
+
.Builder()
|
|
100
|
+
.setUsage(C.USAGE_MEDIA)
|
|
101
|
+
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
102
|
+
.build()
|
|
103
|
+
|
|
89
104
|
player =
|
|
90
105
|
ExoPlayer
|
|
91
106
|
.Builder(context)
|
|
92
107
|
.setLoadControl(loadControl)
|
|
108
|
+
.setAudioAttributes(audioAttributes, true) // handleAudioFocus = true for gapless
|
|
109
|
+
.setHandleAudioBecomingNoisy(true) // Pause when headphones disconnected
|
|
110
|
+
.setPauseAtEndOfMediaItems(false) // Don't pause between items - key for gapless!
|
|
93
111
|
.build()
|
|
112
|
+
|
|
113
|
+
println("🎵 TrackPlayerCore: Gapless playback configured - 120s buffer, audio focus handling enabled")
|
|
94
114
|
mediaSessionManager =
|
|
95
115
|
MediaSessionManager(context, player, playlistManager).apply {
|
|
96
116
|
setTrackPlayerCore(this@TrackPlayerCore)
|
|
@@ -652,4 +672,21 @@ class TrackPlayerCore private constructor(
|
|
|
652
672
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
|
|
653
673
|
}
|
|
654
674
|
}
|
|
675
|
+
|
|
676
|
+
// Set volume (0-100 range, converted to 0.0-1.0 for ExoPlayer)
|
|
677
|
+
fun setVolume(volume: Double): Boolean =
|
|
678
|
+
if (::player.isInitialized) {
|
|
679
|
+
handler.post {
|
|
680
|
+
// Clamp volume to 0-100 range
|
|
681
|
+
val clampedVolume = volume.coerceIn(0.0, 100.0)
|
|
682
|
+
// Convert to 0.0-1.0 range for ExoPlayer
|
|
683
|
+
val normalizedVolume = (clampedVolume / 100.0).toFloat()
|
|
684
|
+
player.volume = normalizedVolume
|
|
685
|
+
println("🔊 TrackPlayerCore: Volume set to $clampedVolume% (normalized: $normalizedVolume)")
|
|
686
|
+
}
|
|
687
|
+
true
|
|
688
|
+
} else {
|
|
689
|
+
println("⚠️ TrackPlayerCore: Cannot set volume - player not initialized")
|
|
690
|
+
false
|
|
691
|
+
}
|
|
655
692
|
}
|
|
@@ -101,4 +101,10 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
101
101
|
func isAndroidAutoConnected() throws -> Bool {
|
|
102
102
|
return false
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Volume Control
|
|
106
|
+
|
|
107
|
+
func setVolume(volume: Double) throws -> Bool {
|
|
108
|
+
return core.setVolume(volume: volume)
|
|
109
|
+
}
|
|
104
110
|
}
|
|
@@ -31,6 +31,13 @@ class TrackPlayerCore: NSObject {
|
|
|
31
31
|
// UI/Display constants
|
|
32
32
|
static let separatorLineLength: Int = 80
|
|
33
33
|
static let playlistSeparatorLength: Int = 40
|
|
34
|
+
|
|
35
|
+
// Gapless playback configuration
|
|
36
|
+
static let preferredForwardBufferDuration: Double = 30.0 // Buffer 30 seconds ahead
|
|
37
|
+
static let preloadAssetKeys: [String] = [
|
|
38
|
+
"playable", "duration", "tracks", "preferredTransform",
|
|
39
|
+
]
|
|
40
|
+
static let gaplessPreloadCount: Int = 3 // Number of tracks to preload ahead
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
// MARK: - Properties
|
|
@@ -46,6 +53,10 @@ class TrackPlayerCore: NSObject {
|
|
|
46
53
|
private var boundaryTimeObserver: Any?
|
|
47
54
|
private var currentItemObservers: [NSKeyValueObservation] = []
|
|
48
55
|
|
|
56
|
+
// Gapless playback: Cache for preloaded assets
|
|
57
|
+
private var preloadedAssets: [String: AVURLAsset] = [:]
|
|
58
|
+
private let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
59
|
+
|
|
49
60
|
var onChangeTrack: ((TrackItem, Reason?) -> Void)?
|
|
50
61
|
var onPlaybackStateChange: ((TrackPlayerState, Reason?) -> Void)?
|
|
51
62
|
var onSeek: ((Double, Double) -> Void)?
|
|
@@ -77,6 +88,24 @@ class TrackPlayerCore: NSObject {
|
|
|
77
88
|
|
|
78
89
|
private func setupPlayer() {
|
|
79
90
|
player = AVQueuePlayer()
|
|
91
|
+
|
|
92
|
+
// MARK: - Gapless Playback Configuration
|
|
93
|
+
|
|
94
|
+
// Disable automatic waiting to minimize stalling - this allows smoother transitions
|
|
95
|
+
// between tracks as AVPlayer won't pause to buffer excessively
|
|
96
|
+
player?.automaticallyWaitsToMinimizeStalling = false
|
|
97
|
+
|
|
98
|
+
// Set playback rate to 1.0 immediately when ready (reduces gap between tracks)
|
|
99
|
+
player?.actionAtItemEnd = .advance
|
|
100
|
+
|
|
101
|
+
// Configure for high-quality audio playback with minimal latency
|
|
102
|
+
if #available(iOS 15.0, *) {
|
|
103
|
+
player?.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
print(
|
|
107
|
+
"🎵 TrackPlayerCore: Gapless playback configured - automaticallyWaitsToMinimizeStalling=false")
|
|
108
|
+
|
|
80
109
|
setupPlayerObservers()
|
|
81
110
|
}
|
|
82
111
|
|
|
@@ -446,6 +475,11 @@ class TrackPlayerCore: NSObject {
|
|
|
446
475
|
if currentItem.status == .readyToPlay {
|
|
447
476
|
setupBoundaryTimeObserver()
|
|
448
477
|
}
|
|
478
|
+
|
|
479
|
+
// MARK: - Gapless Playback: Preload upcoming tracks when track changes
|
|
480
|
+
// This ensures the next tracks are ready for seamless transitions
|
|
481
|
+
preloadUpcomingTracks(from: currentTrackIndex + 1)
|
|
482
|
+
cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
|
|
449
483
|
}
|
|
450
484
|
|
|
451
485
|
private func setupCurrentItemObservers(item: AVPlayerItem) {
|
|
@@ -554,6 +588,135 @@ class TrackPlayerCore: NSObject {
|
|
|
554
588
|
mediaSessionManager?.onPlaybackStateChanged()
|
|
555
589
|
}
|
|
556
590
|
|
|
591
|
+
// MARK: - Gapless Playback Helpers
|
|
592
|
+
|
|
593
|
+
/// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
|
|
594
|
+
private func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false)
|
|
595
|
+
-> AVPlayerItem?
|
|
596
|
+
{
|
|
597
|
+
guard let url = URL(string: track.url) else {
|
|
598
|
+
print("❌ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
599
|
+
return nil
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Check if we have a preloaded asset for this track
|
|
603
|
+
let asset: AVURLAsset
|
|
604
|
+
if let preloadedAsset = preloadedAssets[track.id] {
|
|
605
|
+
asset = preloadedAsset
|
|
606
|
+
print("🚀 TrackPlayerCore: Using preloaded asset for \(track.title)")
|
|
607
|
+
} else {
|
|
608
|
+
// Create asset with options optimized for gapless playback
|
|
609
|
+
asset = AVURLAsset(
|
|
610
|
+
url: url,
|
|
611
|
+
options: [
|
|
612
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true // Ensures accurate duration for gapless transitions
|
|
613
|
+
])
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let item = AVPlayerItem(asset: asset)
|
|
617
|
+
|
|
618
|
+
// Configure buffer duration for gapless playback
|
|
619
|
+
// This tells AVPlayer how much content to buffer ahead
|
|
620
|
+
item.preferredForwardBufferDuration = Constants.preferredForwardBufferDuration
|
|
621
|
+
|
|
622
|
+
// Enable automatic loading of item properties for faster starts
|
|
623
|
+
item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
|
|
624
|
+
|
|
625
|
+
// Store track ID for later reference
|
|
626
|
+
item.trackId = track.id
|
|
627
|
+
|
|
628
|
+
// If this is a preload request, start loading asset keys asynchronously
|
|
629
|
+
if isPreload {
|
|
630
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
|
|
631
|
+
// Asset keys are now loaded, which speeds up playback start
|
|
632
|
+
var allKeysLoaded = true
|
|
633
|
+
for key in Constants.preloadAssetKeys {
|
|
634
|
+
var error: NSError?
|
|
635
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
636
|
+
if status == .failed {
|
|
637
|
+
print(
|
|
638
|
+
"⚠️ TrackPlayerCore: Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")"
|
|
639
|
+
)
|
|
640
|
+
allKeysLoaded = false
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if allKeysLoaded {
|
|
644
|
+
print("✅ TrackPlayerCore: All asset keys preloaded for \(track.title)")
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return item
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/// Preloads assets for upcoming tracks to enable gapless playback
|
|
653
|
+
private func preloadUpcomingTracks(from startIndex: Int) {
|
|
654
|
+
preloadQueue.async { [weak self] in
|
|
655
|
+
guard let self = self else { return }
|
|
656
|
+
|
|
657
|
+
let endIndex = min(startIndex + Constants.gaplessPreloadCount, self.currentTracks.count)
|
|
658
|
+
|
|
659
|
+
for i in startIndex..<endIndex {
|
|
660
|
+
let track = self.currentTracks[i]
|
|
661
|
+
|
|
662
|
+
// Skip if already preloaded
|
|
663
|
+
if self.preloadedAssets[track.id] != nil {
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
guard let url = URL(string: track.url) else { continue }
|
|
668
|
+
|
|
669
|
+
let asset = AVURLAsset(
|
|
670
|
+
url: url,
|
|
671
|
+
options: [
|
|
672
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true
|
|
673
|
+
])
|
|
674
|
+
|
|
675
|
+
// Preload essential keys for gapless playback
|
|
676
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
677
|
+
var allKeysLoaded = true
|
|
678
|
+
for key in Constants.preloadAssetKeys {
|
|
679
|
+
var error: NSError?
|
|
680
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
681
|
+
if status != .loaded {
|
|
682
|
+
allKeysLoaded = false
|
|
683
|
+
break
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if allKeysLoaded {
|
|
688
|
+
DispatchQueue.main.async {
|
|
689
|
+
self?.preloadedAssets[track.id] = asset
|
|
690
|
+
print("🎯 TrackPlayerCore: Preloaded asset for upcoming track: \(track.title)")
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// Clears preloaded assets that are no longer needed
|
|
699
|
+
private func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
|
|
700
|
+
preloadQueue.async { [weak self] in
|
|
701
|
+
guard let self = self else { return }
|
|
702
|
+
|
|
703
|
+
// Keep assets for current track and upcoming tracks within preload range
|
|
704
|
+
let keepRange =
|
|
705
|
+
currentIndex..<min(
|
|
706
|
+
currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
|
|
707
|
+
let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
|
|
708
|
+
|
|
709
|
+
let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
|
|
710
|
+
for id in assetsToRemove {
|
|
711
|
+
self.preloadedAssets.removeValue(forKey: id)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if !assetsToRemove.isEmpty {
|
|
715
|
+
print("🧹 TrackPlayerCore: Cleaned up \(assetsToRemove.count) preloaded assets")
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
557
720
|
// MARK: - Queue Management
|
|
558
721
|
|
|
559
722
|
private func updatePlayerQueue(tracks: [TrackItem]) {
|
|
@@ -578,40 +741,18 @@ class TrackPlayerCore: NSObject {
|
|
|
578
741
|
boundaryTimeObserver = nil
|
|
579
742
|
}
|
|
580
743
|
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
guard let url = URL(string: track.url) else {
|
|
584
|
-
print("❌ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
585
|
-
return nil
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
let item = AVPlayerItem(url: url)
|
|
589
|
-
|
|
590
|
-
// Set metadata using AVMutableMetadataItem
|
|
591
|
-
let metadata = AVMutableMetadataItem()
|
|
592
|
-
metadata.identifier = .commonIdentifierTitle
|
|
593
|
-
metadata.value = track.title as NSString
|
|
594
|
-
metadata.locale = Locale.current
|
|
595
|
-
|
|
596
|
-
let artistMetadata = AVMutableMetadataItem()
|
|
597
|
-
artistMetadata.identifier = .commonIdentifierArtist
|
|
598
|
-
artistMetadata.value = track.artist as NSString
|
|
599
|
-
artistMetadata.locale = Locale.current
|
|
600
|
-
|
|
601
|
-
let albumMetadata = AVMutableMetadataItem()
|
|
602
|
-
albumMetadata.identifier = .commonIdentifierAlbumName
|
|
603
|
-
albumMetadata.value = track.album as NSString
|
|
604
|
-
albumMetadata.locale = Locale.current
|
|
605
|
-
|
|
606
|
-
// Note: AVPlayerItem doesn't have externalMetadata property
|
|
607
|
-
// Metadata will be set via MPNowPlayingInfoCenter in MediaSessionManager
|
|
744
|
+
// Clear old preloaded assets when loading new queue
|
|
745
|
+
preloadedAssets.removeAll()
|
|
608
746
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
747
|
+
// Create gapless-optimized AVPlayerItems from tracks
|
|
748
|
+
let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
|
|
749
|
+
// First few items get preload treatment for faster initial playback
|
|
750
|
+
let isPreload = index < Constants.gaplessPreloadCount
|
|
751
|
+
return createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
613
752
|
}
|
|
614
753
|
|
|
754
|
+
print("🎵 TrackPlayerCore: Created \(items.count) gapless-optimized player items")
|
|
755
|
+
|
|
615
756
|
guard !items.isEmpty else {
|
|
616
757
|
print("❌ TrackPlayerCore: No valid items to play")
|
|
617
758
|
return
|
|
@@ -672,7 +813,10 @@ class TrackPlayerCore: NSObject {
|
|
|
672
813
|
mediaSessionManager?.onTrackChanged()
|
|
673
814
|
}
|
|
674
815
|
|
|
675
|
-
|
|
816
|
+
// Start preloading upcoming tracks for gapless playback
|
|
817
|
+
preloadUpcomingTracks(from: 1)
|
|
818
|
+
|
|
819
|
+
print("✅ TrackPlayerCore: Queue updated with \(items.count) gapless-optimized tracks")
|
|
676
820
|
}
|
|
677
821
|
|
|
678
822
|
func getCurrentTrack() -> TrackItem? {
|
|
@@ -966,6 +1110,28 @@ class TrackPlayerCore: NSObject {
|
|
|
966
1110
|
return playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
|
|
967
1111
|
}
|
|
968
1112
|
|
|
1113
|
+
// MARK: - Volume Control
|
|
1114
|
+
|
|
1115
|
+
func setVolume(volume: Double) -> Bool {
|
|
1116
|
+
guard let player = player else {
|
|
1117
|
+
print("⚠️ TrackPlayerCore: Cannot set volume - no player available")
|
|
1118
|
+
return false
|
|
1119
|
+
}
|
|
1120
|
+
DispatchQueue.main.async { [weak self] in
|
|
1121
|
+
guard let self = self, let currentPlayer = self.player else {
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
// Clamp volume to 0-100 range
|
|
1125
|
+
let clampedVolume = max(0.0, min(100.0, volume))
|
|
1126
|
+
// Convert to 0.0-1.0 range for AVQueuePlayer
|
|
1127
|
+
let normalizedVolume = Float(clampedVolume / 100.0)
|
|
1128
|
+
currentPlayer.volume = normalizedVolume
|
|
1129
|
+
print(
|
|
1130
|
+
"🔊 TrackPlayerCore: Volume set to \(Int(clampedVolume))% (normalized: \(normalizedVolume))")
|
|
1131
|
+
}
|
|
1132
|
+
return true
|
|
1133
|
+
}
|
|
1134
|
+
|
|
969
1135
|
func playFromIndex(index: Int) {
|
|
970
1136
|
DispatchQueue.main.async { [weak self] in
|
|
971
1137
|
guard let self = self,
|
|
@@ -988,14 +1154,15 @@ class TrackPlayerCore: NSObject {
|
|
|
988
1154
|
// Recreate the queue starting from the target index
|
|
989
1155
|
// This ensures all remaining tracks are in the queue
|
|
990
1156
|
let tracksToPlay = Array(fullPlaylist[index...])
|
|
991
|
-
print(
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1157
|
+
print(
|
|
1158
|
+
" 🔄 Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)"
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
// Create gapless-optimized player items
|
|
1162
|
+
let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
|
|
1163
|
+
// First few items get preload treatment for faster playback
|
|
1164
|
+
let isPreload = offset < Constants.gaplessPreloadCount
|
|
1165
|
+
return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
999
1166
|
}
|
|
1000
1167
|
|
|
1001
1168
|
guard let player = self.player, !items.isEmpty else {
|
|
@@ -1020,13 +1187,16 @@ class TrackPlayerCore: NSObject {
|
|
|
1020
1187
|
// Restore the full playlist reference (don't slice it!)
|
|
1021
1188
|
self.currentTracks = fullPlaylist
|
|
1022
1189
|
|
|
1023
|
-
print(" ✅
|
|
1190
|
+
print(" ✅ Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
|
|
1024
1191
|
if let track = self.getCurrentTrack() {
|
|
1025
1192
|
print(" 🎵 Playing: \(track.title)")
|
|
1026
1193
|
self.onChangeTrack?(track, .skip)
|
|
1027
1194
|
self.mediaSessionManager?.onTrackChanged()
|
|
1028
1195
|
}
|
|
1029
1196
|
|
|
1197
|
+
// Start preloading upcoming tracks for gapless playback
|
|
1198
|
+
self.preloadUpcomingTracks(from: index + 1)
|
|
1199
|
+
|
|
1030
1200
|
player.play()
|
|
1031
1201
|
}
|
|
1032
1202
|
}
|
|
@@ -1036,6 +1206,9 @@ class TrackPlayerCore: NSObject {
|
|
|
1036
1206
|
deinit {
|
|
1037
1207
|
print("🧹 TrackPlayerCore: Cleaning up...")
|
|
1038
1208
|
|
|
1209
|
+
// Clear preloaded assets for gapless playback
|
|
1210
|
+
preloadedAssets.removeAll()
|
|
1211
|
+
|
|
1039
1212
|
// Remove boundary time observer
|
|
1040
1213
|
if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
|
|
1041
1214
|
currentPlayer.removeTimeObserver(boundaryObserver)
|
|
@@ -38,4 +38,5 @@ export interface TrackPlayer extends HybridObject<{
|
|
|
38
38
|
onPlaybackProgressChange(callback: (position: number, totalDuration: number, isManuallySeeked?: boolean) => void): void;
|
|
39
39
|
onAndroidAutoConnectionChange(callback: (connected: boolean) => void): void;
|
|
40
40
|
isAndroidAutoConnected(): boolean;
|
|
41
|
+
setVolume(volume: number): boolean;
|
|
41
42
|
}
|
|
@@ -142,5 +142,10 @@ namespace margelo::nitro::nitroplayer {
|
|
|
142
142
|
auto __result = method(_javaPart);
|
|
143
143
|
return static_cast<bool>(__result);
|
|
144
144
|
}
|
|
145
|
+
bool JHybridTrackPlayerSpec::setVolume(double volume) {
|
|
146
|
+
static const auto method = javaClassStatic()->getMethod<jboolean(double /* volume */)>("setVolume");
|
|
147
|
+
auto __result = method(_javaPart, volume);
|
|
148
|
+
return static_cast<bool>(__result);
|
|
149
|
+
}
|
|
145
150
|
|
|
146
151
|
} // namespace margelo::nitro::nitroplayer
|
|
@@ -69,6 +69,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
69
69
|
void onPlaybackProgressChange(const std::function<void(double /* position */, double /* totalDuration */, std::optional<bool> /* isManuallySeeked */)>& callback) override;
|
|
70
70
|
void onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) override;
|
|
71
71
|
bool isAndroidAutoConnected() override;
|
|
72
|
+
bool setVolume(double volume) override;
|
|
72
73
|
|
|
73
74
|
private:
|
|
74
75
|
friend HybridBase;
|
package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt
CHANGED
|
@@ -129,6 +129,10 @@ abstract class HybridTrackPlayerSpec: HybridObject() {
|
|
|
129
129
|
@DoNotStrip
|
|
130
130
|
@Keep
|
|
131
131
|
abstract fun isAndroidAutoConnected(): Boolean
|
|
132
|
+
|
|
133
|
+
@DoNotStrip
|
|
134
|
+
@Keep
|
|
135
|
+
abstract fun setVolume(volume: Double): Boolean
|
|
132
136
|
|
|
133
137
|
private external fun initHybrid(): HybridData
|
|
134
138
|
|
|
@@ -177,6 +177,14 @@ namespace margelo::nitro::nitroplayer {
|
|
|
177
177
|
auto __value = std::move(__result.value());
|
|
178
178
|
return __value;
|
|
179
179
|
}
|
|
180
|
+
inline bool setVolume(double volume) override {
|
|
181
|
+
auto __result = _swiftPart.setVolume(std::forward<decltype(volume)>(volume));
|
|
182
|
+
if (__result.hasError()) [[unlikely]] {
|
|
183
|
+
std::rethrow_exception(__result.error());
|
|
184
|
+
}
|
|
185
|
+
auto __value = std::move(__result.value());
|
|
186
|
+
return __value;
|
|
187
|
+
}
|
|
180
188
|
|
|
181
189
|
private:
|
|
182
190
|
NitroPlayer::HybridTrackPlayerSpec_cxx _swiftPart;
|
|
@@ -29,6 +29,7 @@ public protocol HybridTrackPlayerSpec_protocol: HybridObject {
|
|
|
29
29
|
func onPlaybackProgressChange(callback: @escaping (_ position: Double, _ totalDuration: Double, _ isManuallySeeked: Bool?) -> Void) throws -> Void
|
|
30
30
|
func onAndroidAutoConnectionChange(callback: @escaping (_ connected: Bool) -> Void) throws -> Void
|
|
31
31
|
func isAndroidAutoConnected() throws -> Bool
|
|
32
|
+
func setVolume(volume: Double) throws -> Bool
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
public extension HybridTrackPlayerSpec_protocol {
|
|
@@ -334,4 +334,16 @@ open class HybridTrackPlayerSpec_cxx {
|
|
|
334
334
|
return bridge.create_Result_bool_(__exceptionPtr)
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
@inline(__always)
|
|
339
|
+
public final func setVolume(volume: Double) -> bridge.Result_bool_ {
|
|
340
|
+
do {
|
|
341
|
+
let __result = try self.__implementation.setVolume(volume: volume)
|
|
342
|
+
let __resultCpp = __result
|
|
343
|
+
return bridge.create_Result_bool_(__resultCpp)
|
|
344
|
+
} catch (let __error) {
|
|
345
|
+
let __exceptionPtr = __error.toCpp()
|
|
346
|
+
return bridge.create_Result_bool_(__exceptionPtr)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
337
349
|
}
|
|
@@ -29,6 +29,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
29
29
|
prototype.registerHybridMethod("onPlaybackProgressChange", &HybridTrackPlayerSpec::onPlaybackProgressChange);
|
|
30
30
|
prototype.registerHybridMethod("onAndroidAutoConnectionChange", &HybridTrackPlayerSpec::onAndroidAutoConnectionChange);
|
|
31
31
|
prototype.registerHybridMethod("isAndroidAutoConnected", &HybridTrackPlayerSpec::isAndroidAutoConnected);
|
|
32
|
+
prototype.registerHybridMethod("setVolume", &HybridTrackPlayerSpec::setVolume);
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -82,6 +82,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
82
82
|
virtual void onPlaybackProgressChange(const std::function<void(double /* position */, double /* totalDuration */, std::optional<bool> /* isManuallySeeked */)>& callback) = 0;
|
|
83
83
|
virtual void onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) = 0;
|
|
84
84
|
virtual bool isAndroidAutoConnected() = 0;
|
|
85
|
+
virtual bool setVolume(double volume) = 0;
|
|
85
86
|
|
|
86
87
|
protected:
|
|
87
88
|
// Hybrid Setup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.7",
|
|
4
4
|
"description": "react-native-nitro-player",
|
|
5
5
|
"main": "lib/index",
|
|
6
6
|
"module": "lib/index",
|
|
@@ -30,13 +30,14 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"postinstall": "tsc || exit 0;",
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
|
-
"clean": "rm -rf android/build node_modules/**/android/build lib nitrogen",
|
|
33
|
+
"clean": "rm -rf android/build node_modules/**/android/build lib nitrogen tsconfig.tsbuildinfo",
|
|
34
34
|
"lint": "eslint \"**/*.{js,ts,tsx}\" --fix",
|
|
35
35
|
"lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions",
|
|
36
36
|
"typescript": "tsc",
|
|
37
|
-
"specs": "tsc
|
|
37
|
+
"specs": "tsc && nitrogen --logLevel=\"debug\"",
|
|
38
|
+
"copy-readme": "cp ../README.md README.md",
|
|
38
39
|
"release": "release-it",
|
|
39
|
-
"build": "bun run clean && bun run specs && bun run typescript"
|
|
40
|
+
"build": "bun run clean && bun run specs && bun run typescript && bun run copy-readme"
|
|
40
41
|
},
|
|
41
42
|
"keywords": [
|
|
42
43
|
"react-native",
|
|
@@ -123,6 +124,9 @@
|
|
|
123
124
|
"github": {
|
|
124
125
|
"release": true
|
|
125
126
|
},
|
|
127
|
+
"hooks": {
|
|
128
|
+
"before:release": "bun run clean && bun run specs && bun run typescript && bun run copy-readme"
|
|
129
|
+
},
|
|
126
130
|
"plugins": {
|
|
127
131
|
"@release-it/bumper": {
|
|
128
132
|
"in": "package.json",
|