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 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 ready (native)
11
- - Android foreground service + media notification
12
- - Audio focus handling (Android)
13
- - Simple async JS API
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
- ## Usage
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
- ### Types
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()
@@ -98,7 +98,7 @@ class ExpoNativeTrackPlayerService : MediaSessionService() {
98
98
  }
99
99
  })
100
100
  .build()
101
- .apply { setPlayer(player) }
101
+ .also { manager -> manager.setPlayer(player) }
102
102
  } else {
103
103
  notificationManager?.setPlayer(player)
104
104
  }
@@ -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.1.1",
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.0",
70
- "@react-native/eslint-config": "0.83.0",
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.0",
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 (error) {
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 (error) {
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 (error) {
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 (error) {
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
+ }