expo-native-track-player 0.1.1 → 0.2.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.
- package/CHANGELOG.md +22 -0
- package/README.md +22 -33
- package/android/src/main/java/com/exponativetrackplayer/ExpoNativeTrackPlayerModule.kt +83 -0
- package/android/src/main/java/com/exponativetrackplayer/ExpoNativeTrackPlayerService.kt +1 -1
- package/ios/ExpoNativeTrackPlayer.mm +219 -0
- package/package.json +13 -6
- package/src/NativeExpoNativeTrackPlayer.ts +13 -0
- package/src/hooks.ts +4 -4
- package/src/index.tsx +7 -0
- package/src/types/PlaybackSnapshot.ts +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-02-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Core TrackPlayer module with constants, events, hooks, and native integration.
|
|
9
|
+
- Event handling with typed payloads and event constants.
|
|
10
|
+
- Playback snapshot type for persisted playback state.
|
|
11
|
+
- Track metadata, resource objects, and extended track types.
|
|
12
|
+
- Example app scaffold for testing core playback flows.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Improved ExpoNativeTrackPlayer playback state management and event emission.
|
|
16
|
+
- Expanded metadata handling and resource resolution for tracks.
|
|
17
|
+
- Commitlint configuration allows up to 500 characters in header.
|
|
18
|
+
- `.gitignore` includes Yarn install state and common caches.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Project configuration and documentation updates for initial setup.
|
|
22
|
+
|
package/README.md
CHANGED
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
TurboModule track player for React Native New Architecture (iOS + Android).
|
|
4
4
|
|
|
5
|
+
## Docs
|
|
6
|
+
|
|
7
|
+
- [Full documentation](docs/README.md)
|
|
8
|
+
- [Types reference](docs/types.md)
|
|
9
|
+
|
|
5
10
|
## Features
|
|
6
11
|
|
|
7
|
-
- Native queue management
|
|
12
|
+
- Native queue management (no JS-side queue required)
|
|
8
13
|
- Repeat modes: `off`, `track`, `queue`, `loop_portion`
|
|
9
|
-
- Full track metadata
|
|
10
|
-
- Background playback
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
+
- Full track metadata + custom fields
|
|
15
|
+
- Background playback with lock-screen / notification controls
|
|
16
|
+
- iOS: MPRemoteCommandCenter + Now Playing info
|
|
17
|
+
- Android: MediaSession + foreground service + notification
|
|
18
|
+
- Playback snapshots (native) for recovery after background/kill
|
|
19
|
+
- Typed events + hooks (`useProgress`, `useQueue`, etc.)
|
|
14
20
|
|
|
15
21
|
## Installation (outside this repo)
|
|
16
22
|
|
|
@@ -78,7 +84,7 @@ Enable background audio in your app target:
|
|
|
78
84
|
### Android
|
|
79
85
|
|
|
80
86
|
This module uses a foreground media service for reliable background playback.
|
|
81
|
-
Ensure your app has these permissions:
|
|
87
|
+
Ensure your app has these permissions (app manifest):
|
|
82
88
|
|
|
83
89
|
```xml
|
|
84
90
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
@@ -91,7 +97,7 @@ media notification to appear.
|
|
|
91
97
|
|
|
92
98
|
Make sure you test background playback on a real device.
|
|
93
99
|
|
|
94
|
-
##
|
|
100
|
+
## Quick start
|
|
95
101
|
|
|
96
102
|
```tsx
|
|
97
103
|
import TrackPlayer, {
|
|
@@ -112,34 +118,11 @@ await TrackPlayer.addToQueue(track);
|
|
|
112
118
|
await TrackPlayer.play(null);
|
|
113
119
|
```
|
|
114
120
|
|
|
115
|
-
## API
|
|
121
|
+
## API (summary)
|
|
116
122
|
|
|
117
123
|
All time values are milliseconds unless stated otherwise.
|
|
118
124
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
```ts
|
|
122
|
-
export interface TrackMetadata {
|
|
123
|
-
id: string;
|
|
124
|
-
url: string;
|
|
125
|
-
title?: string;
|
|
126
|
-
artist?: string;
|
|
127
|
-
albumName?: string;
|
|
128
|
-
artworkUri?: string;
|
|
129
|
-
trackNumber?: number;
|
|
130
|
-
composer?: string;
|
|
131
|
-
conductor?: string;
|
|
132
|
-
genre?: string;
|
|
133
|
-
compilation?: string;
|
|
134
|
-
subtitle?: string;
|
|
135
|
-
description?: string;
|
|
136
|
-
station?: string;
|
|
137
|
-
mediaType?: number;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export type RepeatMode = 'off' | 'track' | 'queue' | 'loop_portion';
|
|
141
|
-
export type PlaybackState = 'playing' | 'paused' | 'stopped';
|
|
142
|
-
```
|
|
125
|
+
See [Types reference](docs/types.md) for the full list.
|
|
143
126
|
|
|
144
127
|
### Queue
|
|
145
128
|
|
|
@@ -203,6 +186,12 @@ export type PlaybackState = 'playing' | 'paused' | 'stopped';
|
|
|
203
186
|
- Ensure device volume and notification permission are enabled.
|
|
204
187
|
- Test on a real device; emulators are not reliable for audio focus/Doze.
|
|
205
188
|
|
|
189
|
+
### iOS: snapshot crash on load/reset
|
|
190
|
+
|
|
191
|
+
- Snapshots are saved natively and must be valid property list values.
|
|
192
|
+
- If you changed native code recently, delete the app to clear old snapshots.
|
|
193
|
+
- Avoid `NaN`/`Infinity` positions; use `0` when duration is unknown.
|
|
194
|
+
|
|
206
195
|
### Android: queue empty after add
|
|
207
196
|
|
|
208
197
|
- The example app sets a local queue immediately and then syncs from native.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.exponativetrackplayer
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
3
5
|
import android.os.Handler
|
|
4
6
|
import android.os.Looper
|
|
5
7
|
import androidx.media3.common.AudioAttributes
|
|
@@ -19,6 +21,7 @@ import com.facebook.react.bridge.ReactApplicationContext
|
|
|
19
21
|
import com.facebook.react.bridge.WritableArray
|
|
20
22
|
import com.facebook.react.bridge.WritableMap
|
|
21
23
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
24
|
+
import org.json.JSONObject
|
|
22
25
|
import java.util.concurrent.CountDownLatch
|
|
23
26
|
import java.util.concurrent.atomic.AtomicReference
|
|
24
27
|
|
|
@@ -33,6 +36,12 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
33
36
|
private var loopEndMs: Long? = null
|
|
34
37
|
private var playbackState: String = "stopped"
|
|
35
38
|
private var playbackRate: Float = 1f
|
|
39
|
+
private var lastSnapshotSavedAtMs: Long = 0
|
|
40
|
+
private val snapshotPrefs: SharedPreferences =
|
|
41
|
+
reactApplicationContext.getSharedPreferences(
|
|
42
|
+
"expo-native-track-player",
|
|
43
|
+
Context.MODE_PRIVATE
|
|
44
|
+
)
|
|
36
45
|
|
|
37
46
|
private val eventPlaybackState = "playback-state"
|
|
38
47
|
private val eventPlaybackPosition = "playback-progress-updated"
|
|
@@ -48,6 +57,8 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
48
57
|
ExoPlayer.Builder(reactContext)
|
|
49
58
|
.setAudioAttributes(audioAttributes, true)
|
|
50
59
|
.setHandleAudioBecomingNoisy(true)
|
|
60
|
+
.setSeekForwardIncrementMs(15000)
|
|
61
|
+
.setSeekBackIncrementMs(15000)
|
|
51
62
|
.build()
|
|
52
63
|
.apply {
|
|
53
64
|
addListener(object : Player.Listener {
|
|
@@ -251,6 +262,15 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
251
262
|
return playbackRate.toDouble()
|
|
252
263
|
}
|
|
253
264
|
|
|
265
|
+
override fun getLastPlaybackSnapshot(): WritableMap? {
|
|
266
|
+
val raw = snapshotPrefs.getString("snapshot", null) ?: return null
|
|
267
|
+
return try {
|
|
268
|
+
jsonToWritableMap(JSONObject(raw))
|
|
269
|
+
} catch (error: Exception) {
|
|
270
|
+
null
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
override fun addListener(eventName: String) {
|
|
255
275
|
}
|
|
256
276
|
|
|
@@ -328,6 +348,7 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
328
348
|
putString("state", playbackState)
|
|
329
349
|
}
|
|
330
350
|
emitEvent(eventPlaybackState, payload)
|
|
351
|
+
saveSnapshotWithCurrentValues(force = true)
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
private fun emitQueueUpdated() {
|
|
@@ -363,6 +384,66 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
363
384
|
putInt("trackIndex", currentIndex)
|
|
364
385
|
}
|
|
365
386
|
emitEvent(eventPlaybackPosition, payload)
|
|
387
|
+
saveSnapshotIfNeeded(
|
|
388
|
+
positionMs = player.currentPosition,
|
|
389
|
+
durationMs = if (duration > 0) duration else 0,
|
|
390
|
+
trackIndex = currentIndex,
|
|
391
|
+
force = false
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun saveSnapshotWithCurrentValues(force: Boolean) {
|
|
396
|
+
val duration = player.duration
|
|
397
|
+
saveSnapshotIfNeeded(
|
|
398
|
+
positionMs = player.currentPosition,
|
|
399
|
+
durationMs = if (duration > 0) duration else 0,
|
|
400
|
+
trackIndex = currentIndex,
|
|
401
|
+
force = force
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private fun saveSnapshotIfNeeded(
|
|
406
|
+
positionMs: Long,
|
|
407
|
+
durationMs: Long,
|
|
408
|
+
trackIndex: Int,
|
|
409
|
+
force: Boolean
|
|
410
|
+
) {
|
|
411
|
+
val now = System.currentTimeMillis()
|
|
412
|
+
if (!force && now - lastSnapshotSavedAtMs < 1000) return
|
|
413
|
+
lastSnapshotSavedAtMs = now
|
|
414
|
+
val trackId = queue.getOrNull(trackIndex)?.get("id") as? String
|
|
415
|
+
val snapshot = JSONObject().apply {
|
|
416
|
+
put("state", playbackState)
|
|
417
|
+
put("position", positionMs.toDouble())
|
|
418
|
+
put("duration", durationMs.toDouble())
|
|
419
|
+
put("trackIndex", trackIndex)
|
|
420
|
+
if (trackId != null) put("trackId", trackId) else put("trackId", JSONObject.NULL)
|
|
421
|
+
put("rate", playbackRate.toDouble())
|
|
422
|
+
put("volume", player.volume.toDouble())
|
|
423
|
+
put("repeatMode", repeatMode)
|
|
424
|
+
put("savedAt", now.toDouble())
|
|
425
|
+
}
|
|
426
|
+
snapshotPrefs.edit().putString("snapshot", snapshot.toString()).apply()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private fun jsonToWritableMap(json: JSONObject): WritableMap {
|
|
430
|
+
val map = Arguments.createMap()
|
|
431
|
+
val keys = json.keys()
|
|
432
|
+
while (keys.hasNext()) {
|
|
433
|
+
val key = keys.next()
|
|
434
|
+
val value = json.get(key)
|
|
435
|
+
when (value) {
|
|
436
|
+
JSONObject.NULL -> map.putNull(key)
|
|
437
|
+
is String -> map.putString(key, value)
|
|
438
|
+
is Int -> map.putInt(key, value)
|
|
439
|
+
is Long -> map.putDouble(key, value.toDouble())
|
|
440
|
+
is Double -> map.putDouble(key, value)
|
|
441
|
+
is Float -> map.putDouble(key, value.toDouble())
|
|
442
|
+
is Boolean -> map.putBoolean(key, value)
|
|
443
|
+
else -> map.putString(key, value.toString())
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return map
|
|
366
447
|
}
|
|
367
448
|
|
|
368
449
|
private fun emitPlaybackQueueEnded() {
|
|
@@ -421,6 +502,7 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
421
502
|
val title = track["title"] as? String
|
|
422
503
|
val artist = track["artist"] as? String
|
|
423
504
|
val albumName = track["albumName"] as? String
|
|
505
|
+
val artworkUri = track["artworkUri"] as? String
|
|
424
506
|
return MediaItem.Builder()
|
|
425
507
|
.setMediaId(id)
|
|
426
508
|
.setUri(url)
|
|
@@ -429,6 +511,7 @@ class ExpoNativeTrackPlayerModule(reactContext: ReactApplicationContext) :
|
|
|
429
511
|
.setTitle(title)
|
|
430
512
|
.setArtist(artist)
|
|
431
513
|
.setAlbumTitle(albumName)
|
|
514
|
+
.setArtworkUri(if (artworkUri != null) android.net.Uri.parse(artworkUri) else null)
|
|
432
515
|
.build()
|
|
433
516
|
)
|
|
434
517
|
.build()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#import "ExpoNativeTrackPlayer.h"
|
|
2
2
|
#import <AVFoundation/AVFoundation.h>
|
|
3
|
+
#import <MediaPlayer/MediaPlayer.h>
|
|
3
4
|
#import <React/RCTBridge.h>
|
|
4
5
|
|
|
5
6
|
@interface ExpoNativeTrackPlayer ()
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
@property (nonatomic, assign) float playbackRate;
|
|
15
16
|
@property (nonatomic, strong, nullable) id timeObserver;
|
|
16
17
|
@property (nonatomic, strong, nullable) id endObserver;
|
|
18
|
+
@property (nonatomic, assign) double lastSnapshotSavedAtMs;
|
|
17
19
|
|
|
18
20
|
@end
|
|
19
21
|
|
|
@@ -29,6 +31,8 @@ static NSString *const ExpoNativeTrackPlayerEventPlaybackActiveTrackChanged =
|
|
|
29
31
|
static NSString *const ExpoNativeTrackPlayerEventPlaybackQueueEnded =
|
|
30
32
|
@"playback-queue-ended";
|
|
31
33
|
static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated";
|
|
34
|
+
static NSString *const ExpoNativeTrackPlayerSnapshotKey =
|
|
35
|
+
@"expo-native-track-player.snapshot";
|
|
32
36
|
|
|
33
37
|
- (instancetype)init
|
|
34
38
|
{
|
|
@@ -40,6 +44,7 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
40
44
|
_playbackState = @"stopped";
|
|
41
45
|
_playbackRate = 1.0f;
|
|
42
46
|
[self configureAudioSession];
|
|
47
|
+
[self setupRemoteCommands];
|
|
43
48
|
[self startLoopWatcher];
|
|
44
49
|
}
|
|
45
50
|
return self;
|
|
@@ -117,6 +122,7 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
117
122
|
[self emitEvent:ExpoNativeTrackPlayerEventPlaybackState body:@{
|
|
118
123
|
@"state": self.playbackState ?: @"stopped"
|
|
119
124
|
}];
|
|
125
|
+
[self updateNowPlayingInfo];
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
- (void)emitQueueUpdated
|
|
@@ -140,6 +146,7 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
140
146
|
@"track": track ?: (id)kCFNull
|
|
141
147
|
};
|
|
142
148
|
[self emitEvent:ExpoNativeTrackPlayerEventPlaybackActiveTrackChanged body:payload];
|
|
149
|
+
[self updateNowPlayingInfo];
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
- (void)emitPlaybackPositionWithPosition:(double)position
|
|
@@ -152,6 +159,8 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
152
159
|
@"trackIndex": @(trackIndex)
|
|
153
160
|
};
|
|
154
161
|
[self emitEvent:ExpoNativeTrackPlayerEventPlaybackProgress body:payload];
|
|
162
|
+
[self updateNowPlayingInfoWithPosition:position duration:duration];
|
|
163
|
+
[self saveSnapshotIfNeededWithPosition:position duration:duration trackIndex:trackIndex force:NO];
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
- (void)emitPlaybackQueueEnded
|
|
@@ -456,6 +465,94 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
456
465
|
NSString *nextState = state ?: @"stopped";
|
|
457
466
|
_playbackState = [nextState copy];
|
|
458
467
|
[self emitPlaybackState];
|
|
468
|
+
[self saveSnapshotWithCurrentValuesForce:YES];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
- (NSDictionary *)currentSnapshotWithPosition:(double)position
|
|
472
|
+
duration:(double)duration
|
|
473
|
+
trackIndex:(NSInteger)trackIndex
|
|
474
|
+
{
|
|
475
|
+
NSString *trackId = nil;
|
|
476
|
+
if (trackIndex >= 0 && trackIndex < (NSInteger)self.queue.count) {
|
|
477
|
+
NSDictionary *track = self.queue[(NSUInteger)trackIndex];
|
|
478
|
+
if ([track[@"id"] isKindOfClass:[NSString class]]) {
|
|
479
|
+
trackId = track[@"id"];
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
double savedAt = [[NSDate date] timeIntervalSince1970] * 1000.0;
|
|
483
|
+
double safePosition = isfinite(position) ? position : 0.0;
|
|
484
|
+
double safeDuration = isfinite(duration) ? duration : 0.0;
|
|
485
|
+
NSMutableDictionary *snapshot = [@{
|
|
486
|
+
@"state": self.playbackState ?: @"stopped",
|
|
487
|
+
@"position": @(safePosition),
|
|
488
|
+
@"duration": @(safeDuration),
|
|
489
|
+
@"trackIndex": @(trackIndex),
|
|
490
|
+
@"rate": @(self.playbackRate),
|
|
491
|
+
@"volume": @(self.player.volume),
|
|
492
|
+
@"repeatMode": self.repeatMode ?: @"off",
|
|
493
|
+
@"savedAt": @(savedAt)
|
|
494
|
+
} mutableCopy];
|
|
495
|
+
if (trackId != nil) {
|
|
496
|
+
snapshot[@"trackId"] = trackId;
|
|
497
|
+
}
|
|
498
|
+
return snapshot;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
- (void)saveSnapshotIfNeededWithPosition:(double)position
|
|
502
|
+
duration:(double)duration
|
|
503
|
+
trackIndex:(NSInteger)trackIndex
|
|
504
|
+
force:(BOOL)force
|
|
505
|
+
{
|
|
506
|
+
if (trackIndex < 0) {
|
|
507
|
+
[[NSUserDefaults standardUserDefaults] removeObjectForKey:ExpoNativeTrackPlayerSnapshotKey];
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (!isfinite(position) || !isfinite(duration)) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
double nowMs = [[NSDate date] timeIntervalSince1970] * 1000.0;
|
|
514
|
+
if (!force && nowMs - self.lastSnapshotSavedAtMs < 1000.0) return;
|
|
515
|
+
self.lastSnapshotSavedAtMs = nowMs;
|
|
516
|
+
NSDictionary *snapshot =
|
|
517
|
+
[self currentSnapshotWithPosition:position duration:duration trackIndex:trackIndex];
|
|
518
|
+
if (![NSPropertyListSerialization propertyList:snapshot
|
|
519
|
+
isValidForFormat:NSPropertyListBinaryFormat_v1_0]) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
[[NSUserDefaults standardUserDefaults] setObject:snapshot
|
|
523
|
+
forKey:ExpoNativeTrackPlayerSnapshotKey];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
- (void)saveSnapshotWithCurrentValuesForce:(BOOL)force
|
|
527
|
+
{
|
|
528
|
+
if (self.currentIndex < 0) {
|
|
529
|
+
[[NSUserDefaults standardUserDefaults] removeObjectForKey:ExpoNativeTrackPlayerSnapshotKey];
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
double position = CMTimeGetSeconds([self.player currentTime]) * 1000.0;
|
|
533
|
+
if (!isfinite(position)) {
|
|
534
|
+
position = 0.0;
|
|
535
|
+
}
|
|
536
|
+
double duration = 0.0;
|
|
537
|
+
AVPlayerItem *item = self.player.currentItem;
|
|
538
|
+
if (item != nil) {
|
|
539
|
+
duration = CMTimeGetSeconds(item.duration) * 1000.0;
|
|
540
|
+
if (!isfinite(duration)) {
|
|
541
|
+
duration = 0.0;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
[self saveSnapshotIfNeededWithPosition:position
|
|
545
|
+
duration:duration
|
|
546
|
+
trackIndex:self.currentIndex
|
|
547
|
+
force:force];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
- (NSDictionary * _Nullable)getLastPlaybackSnapshot
|
|
551
|
+
{
|
|
552
|
+
NSDictionary *snapshot =
|
|
553
|
+
[[NSUserDefaults standardUserDefaults] objectForKey:ExpoNativeTrackPlayerSnapshotKey];
|
|
554
|
+
if (![snapshot isKindOfClass:[NSDictionary class]]) return nil;
|
|
555
|
+
return snapshot;
|
|
459
556
|
}
|
|
460
557
|
|
|
461
558
|
- (void)configureAudioSession
|
|
@@ -466,6 +563,128 @@ static NSString *const ExpoNativeTrackPlayerEventQueueUpdated = @"queue-updated"
|
|
|
466
563
|
[session setActive:YES error:&error];
|
|
467
564
|
}
|
|
468
565
|
|
|
566
|
+
- (void)setupRemoteCommands
|
|
567
|
+
{
|
|
568
|
+
MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
|
|
569
|
+
commandCenter.playCommand.enabled = YES;
|
|
570
|
+
commandCenter.pauseCommand.enabled = YES;
|
|
571
|
+
commandCenter.nextTrackCommand.enabled = YES;
|
|
572
|
+
commandCenter.previousTrackCommand.enabled = YES;
|
|
573
|
+
commandCenter.changePlaybackPositionCommand.enabled = YES;
|
|
574
|
+
commandCenter.skipForwardCommand.enabled = YES;
|
|
575
|
+
commandCenter.skipBackwardCommand.enabled = YES;
|
|
576
|
+
commandCenter.skipForwardCommand.preferredIntervals = @[@(15)];
|
|
577
|
+
commandCenter.skipBackwardCommand.preferredIntervals = @[@(15)];
|
|
578
|
+
|
|
579
|
+
__weak ExpoNativeTrackPlayer *weakSelf = self;
|
|
580
|
+
[commandCenter.playCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
581
|
+
[weakSelf playInternal:nil];
|
|
582
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
583
|
+
}];
|
|
584
|
+
[commandCenter.pauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
585
|
+
[weakSelf pause];
|
|
586
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
587
|
+
}];
|
|
588
|
+
[commandCenter.nextTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
589
|
+
[weakSelf skipToNext];
|
|
590
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
591
|
+
}];
|
|
592
|
+
[commandCenter.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
593
|
+
[weakSelf skipToPrevious];
|
|
594
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
595
|
+
}];
|
|
596
|
+
[commandCenter.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
597
|
+
if (![event isKindOfClass:[MPChangePlaybackPositionCommandEvent class]]) {
|
|
598
|
+
return MPRemoteCommandHandlerStatusCommandFailed;
|
|
599
|
+
}
|
|
600
|
+
MPChangePlaybackPositionCommandEvent *seekEvent = (MPChangePlaybackPositionCommandEvent *)event;
|
|
601
|
+
double positionMs = seekEvent.positionTime * 1000.0;
|
|
602
|
+
[weakSelf seekTo:positionMs];
|
|
603
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
604
|
+
}];
|
|
605
|
+
[commandCenter.skipForwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
606
|
+
double position = CMTimeGetSeconds([weakSelf.player currentTime]) * 1000.0;
|
|
607
|
+
[weakSelf seekTo:(position + 15000.0)];
|
|
608
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
609
|
+
}];
|
|
610
|
+
[commandCenter.skipBackwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
|
|
611
|
+
double position = CMTimeGetSeconds([weakSelf.player currentTime]) * 1000.0;
|
|
612
|
+
[weakSelf seekTo:MAX(0.0, position - 15000.0)];
|
|
613
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
614
|
+
}];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
- (void)updateNowPlayingInfo
|
|
618
|
+
{
|
|
619
|
+
NSDictionary *track = nil;
|
|
620
|
+
if (self.currentIndex >= 0 && self.currentIndex < (NSInteger)self.queue.count) {
|
|
621
|
+
track = self.queue[(NSUInteger)self.currentIndex];
|
|
622
|
+
}
|
|
623
|
+
NSMutableDictionary *info = [NSMutableDictionary dictionary];
|
|
624
|
+
if (track[@"title"]) info[MPMediaItemPropertyTitle] = track[@"title"];
|
|
625
|
+
if (track[@"artist"]) info[MPMediaItemPropertyArtist] = track[@"artist"];
|
|
626
|
+
if (track[@"albumName"]) info[MPMediaItemPropertyAlbumTitle] = track[@"albumName"];
|
|
627
|
+
if (track[@"artworkUri"] && [track[@"artworkUri"] isKindOfClass:[NSString class]]) {
|
|
628
|
+
NSString *artworkUri = track[@"artworkUri"];
|
|
629
|
+
NSURL *url = [NSURL URLWithString:artworkUri];
|
|
630
|
+
if (url != nil) {
|
|
631
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
632
|
+
NSData *data = [NSData dataWithContentsOfURL:url];
|
|
633
|
+
UIImage *image = data != nil ? [UIImage imageWithData:data] : nil;
|
|
634
|
+
if (image != nil) {
|
|
635
|
+
MPMediaItemArtwork *artwork =
|
|
636
|
+
[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size
|
|
637
|
+
requestHandler:^UIImage * _Nonnull(CGSize size) {
|
|
638
|
+
return image;
|
|
639
|
+
}];
|
|
640
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
641
|
+
NSMutableDictionary *updated = [info mutableCopy];
|
|
642
|
+
updated[MPMediaItemPropertyArtwork] = artwork;
|
|
643
|
+
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = updated;
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
double position = CMTimeGetSeconds([self.player currentTime]);
|
|
650
|
+
double duration = 0.0;
|
|
651
|
+
AVPlayerItem *item = self.player.currentItem;
|
|
652
|
+
if (item != nil) {
|
|
653
|
+
duration = CMTimeGetSeconds(item.duration);
|
|
654
|
+
if (!isfinite(duration)) {
|
|
655
|
+
duration = 0.0;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (isfinite(position)) {
|
|
659
|
+
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(position);
|
|
660
|
+
}
|
|
661
|
+
info[MPMediaItemPropertyPlaybackDuration] = @(duration);
|
|
662
|
+
info[MPNowPlayingInfoPropertyPlaybackRate] =
|
|
663
|
+
[self.playbackState isEqualToString:@"playing"] ? @(self.playbackRate) : @(0);
|
|
664
|
+
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = info;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
- (void)updateNowPlayingInfoWithPosition:(double)positionMs duration:(double)durationMs
|
|
668
|
+
{
|
|
669
|
+
NSMutableDictionary *info =
|
|
670
|
+
[[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo mutableCopy];
|
|
671
|
+
if (info == nil) {
|
|
672
|
+
[self updateNowPlayingInfo];
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
double position = positionMs / 1000.0;
|
|
676
|
+
double duration = durationMs / 1000.0;
|
|
677
|
+
if (isfinite(position)) {
|
|
678
|
+
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(position);
|
|
679
|
+
}
|
|
680
|
+
if (isfinite(duration)) {
|
|
681
|
+
info[MPMediaItemPropertyPlaybackDuration] = @(duration);
|
|
682
|
+
}
|
|
683
|
+
info[MPNowPlayingInfoPropertyPlaybackRate] =
|
|
684
|
+
[self.playbackState isEqualToString:@"playing"] ? @(self.playbackRate) : @(0);
|
|
685
|
+
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = info;
|
|
686
|
+
}
|
|
687
|
+
|
|
469
688
|
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
470
689
|
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
471
690
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-native-track-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TurboModule track player (New Architecture)",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"react-native": "./src/index.tsx",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"bugs": {
|
|
58
58
|
"url": "/issues"
|
|
59
59
|
},
|
|
60
|
-
"homepage": "#readme",
|
|
60
|
+
"homepage": "https://github.com/StanSarr/expo-native-track-player#readme",
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"registry": "https://registry.npmjs.org/"
|
|
63
63
|
},
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"@eslint/compat": "^1.3.2",
|
|
67
67
|
"@eslint/eslintrc": "^3.3.1",
|
|
68
68
|
"@eslint/js": "^9.35.0",
|
|
69
|
-
"@react-native/babel-preset": "0.83.
|
|
70
|
-
"@react-native/eslint-config": "0.83.
|
|
69
|
+
"@react-native/babel-preset": "0.83.1",
|
|
70
|
+
"@react-native/eslint-config": "0.83.1",
|
|
71
71
|
"@release-it/conventional-changelog": "^10.0.1",
|
|
72
72
|
"@types/jest": "^29.5.14",
|
|
73
73
|
"@types/react": "^19.2.0",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"lefthook": "^2.0.3",
|
|
81
81
|
"prettier": "^2.8.8",
|
|
82
82
|
"react": "19.2.0",
|
|
83
|
-
"react-native": "0.83.
|
|
83
|
+
"react-native": "0.83.1",
|
|
84
84
|
"react-native-builder-bob": "^0.40.17",
|
|
85
85
|
"release-it": "^19.0.4",
|
|
86
86
|
"turbo": "^2.5.6",
|
|
@@ -134,7 +134,14 @@
|
|
|
134
134
|
"commitlint": {
|
|
135
135
|
"extends": [
|
|
136
136
|
"@commitlint/config-conventional"
|
|
137
|
-
]
|
|
137
|
+
],
|
|
138
|
+
"rules": {
|
|
139
|
+
"header-max-length": [
|
|
140
|
+
2,
|
|
141
|
+
"always",
|
|
142
|
+
500
|
|
143
|
+
]
|
|
144
|
+
}
|
|
138
145
|
},
|
|
139
146
|
"release-it": {
|
|
140
147
|
"git": {
|
|
@@ -34,6 +34,18 @@ export type State =
|
|
|
34
34
|
| 'error'
|
|
35
35
|
| 'ended';
|
|
36
36
|
export type PlaybackState = State;
|
|
37
|
+
|
|
38
|
+
export interface PlaybackSnapshot {
|
|
39
|
+
state: State;
|
|
40
|
+
position: number;
|
|
41
|
+
duration: number;
|
|
42
|
+
trackIndex: number;
|
|
43
|
+
trackId?: string;
|
|
44
|
+
rate: number;
|
|
45
|
+
volume: number;
|
|
46
|
+
repeatMode: RepeatMode;
|
|
47
|
+
savedAt: number;
|
|
48
|
+
}
|
|
37
49
|
export interface Spec extends TurboModule {
|
|
38
50
|
addToQueue(track: TrackMetadata): void;
|
|
39
51
|
addQueue(tracks: Array<TrackMetadata>): void;
|
|
@@ -63,6 +75,7 @@ export interface Spec extends TurboModule {
|
|
|
63
75
|
getVolume(): number;
|
|
64
76
|
setRate(rate: number): void;
|
|
65
77
|
getRate(): number;
|
|
78
|
+
getLastPlaybackSnapshot(): PlaybackSnapshot | null;
|
|
66
79
|
addListener(eventName: string): void;
|
|
67
80
|
removeListeners(count: number): void;
|
|
68
81
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function useProgress(intervalMs: number = 1000): PlaybackProgress {
|
|
|
51
51
|
const duration = ExpoNativeTrackPlayer.getDuration();
|
|
52
52
|
const trackIndex = ExpoNativeTrackPlayer.getCurrentTrackIndex();
|
|
53
53
|
setProgress({ position, duration, trackIndex });
|
|
54
|
-
} catch
|
|
54
|
+
} catch {
|
|
55
55
|
setProgress({ position: 0, duration: 0, trackIndex: -1 });
|
|
56
56
|
}
|
|
57
57
|
};
|
|
@@ -76,7 +76,7 @@ export function useQueue(): TrackMetadata[] {
|
|
|
76
76
|
try {
|
|
77
77
|
const result = ExpoNativeTrackPlayer.getQueue();
|
|
78
78
|
if (isActive) setQueue(result);
|
|
79
|
-
} catch
|
|
79
|
+
} catch {
|
|
80
80
|
if (isActive) setQueue([]);
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -103,7 +103,7 @@ export function useCurrentTrack(): TrackMetadata | null {
|
|
|
103
103
|
try {
|
|
104
104
|
const result = ExpoNativeTrackPlayer.getCurrentTrack();
|
|
105
105
|
if (isActive) setTrack(result);
|
|
106
|
-
} catch
|
|
106
|
+
} catch {
|
|
107
107
|
if (isActive) setTrack(null);
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -130,7 +130,7 @@ export function useCurrentTrackIndex(): number {
|
|
|
130
130
|
try {
|
|
131
131
|
const result = ExpoNativeTrackPlayer.getCurrentTrackIndex();
|
|
132
132
|
if (isActive) setIndex(result);
|
|
133
|
-
} catch
|
|
133
|
+
} catch {
|
|
134
134
|
if (isActive) setIndex(-1);
|
|
135
135
|
}
|
|
136
136
|
|
package/src/index.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
useQueue,
|
|
22
22
|
} from './hooks';
|
|
23
23
|
import type { AddTrack, Track } from './types/Track';
|
|
24
|
+
import type { PlaybackSnapshot } from './types/PlaybackSnapshot';
|
|
24
25
|
import type { ResourceObject } from './types/ResourceObject';
|
|
25
26
|
import type { TrackMetadataBase } from './types/TrackMetadataBase';
|
|
26
27
|
import { PitchAlgorithms, TrackTypes } from './constants';
|
|
@@ -188,6 +189,10 @@ export function getRate(): Promise<number> {
|
|
|
188
189
|
return callValue(() => ExpoNativeTrackPlayer.getRate());
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
export function getLastPlaybackSnapshot(): Promise<PlaybackSnapshot | null> {
|
|
193
|
+
return callValue(() => ExpoNativeTrackPlayer.getLastPlaybackSnapshot());
|
|
194
|
+
}
|
|
195
|
+
|
|
191
196
|
const TrackPlayer = {
|
|
192
197
|
addToQueue,
|
|
193
198
|
addQueue,
|
|
@@ -213,6 +218,7 @@ const TrackPlayer = {
|
|
|
213
218
|
getVolume,
|
|
214
219
|
setRate,
|
|
215
220
|
getRate,
|
|
221
|
+
getLastPlaybackSnapshot,
|
|
216
222
|
};
|
|
217
223
|
|
|
218
224
|
export default TrackPlayer;
|
|
@@ -229,6 +235,7 @@ export type {
|
|
|
229
235
|
PitchAlgorithm,
|
|
230
236
|
RepeatModeType,
|
|
231
237
|
StateType,
|
|
238
|
+
PlaybackSnapshot,
|
|
232
239
|
};
|
|
233
240
|
export {
|
|
234
241
|
AudioEvents,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RepeatMode, State } from '../NativeExpoNativeTrackPlayer';
|
|
2
|
+
|
|
3
|
+
export interface PlaybackSnapshot {
|
|
4
|
+
state: State;
|
|
5
|
+
position: number;
|
|
6
|
+
duration: number;
|
|
7
|
+
trackIndex: number;
|
|
8
|
+
trackId?: string;
|
|
9
|
+
rate: number;
|
|
10
|
+
volume: number;
|
|
11
|
+
repeatMode: RepeatMode;
|
|
12
|
+
savedAt: number;
|
|
13
|
+
}
|