From 72b71838548fad94915cb630b7d7175fef9ff37e Mon Sep 17 00:00:00 2001 From: Ritesh Shukla Date: Fri, 2 Jan 2026 21:51:50 +0530 Subject: [PATCH] Persist player progress to mmkv and restore on app opening (#890) Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> --- ...act-native-track-player+5.0.0-alpha0.patch | 444 ++++++++++++++++++ .../Player/functions/initialization.ts | 16 + 2 files changed, 460 insertions(+) create mode 100644 patches/react-native-track-player+5.0.0-alpha0.patch diff --git a/patches/react-native-track-player+5.0.0-alpha0.patch b/patches/react-native-track-player+5.0.0-alpha0.patch new file mode 100644 index 00000000..7b26e23a --- /dev/null +++ b/patches/react-native-track-player+5.0.0-alpha0.patch @@ -0,0 +1,444 @@ +diff --git a/node_modules/react-native-track-player/android/build.gradle b/node_modules/react-native-track-player/android/build.gradle +index 763583c..173c646 100644 +--- a/node_modules/react-native-track-player/android/build.gradle ++++ b/node_modules/react-native-track-player/android/build.gradle +@@ -103,6 +103,7 @@ dependencies { + + implementation 'androidx.test:rules:1.6.1' + implementation 'jp.wasabeef.transformers:coil:1.0.6' ++ implementation "io.github.zhongwuzw:mmkv:2.2.4" + } + + react { +diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt +index 13a6c60..6f2491e 100644 +--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt ++++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt +@@ -31,7 +31,10 @@ import timber.log.Timber + import java.util.* + import javax.annotation.Nonnull + ++import com.tencent.mmkv.MMKV ++ + import com.doublesymmetry.trackplayer.NativeTrackPlayerSpec ++import kotlinx.coroutines.* + + + /** +@@ -44,10 +47,26 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + private var playerOptions: Bundle? = null + private var isServiceBound = false + private var playerSetUpPromise: Promise? = null ++ private val backgroundScope = CoroutineScope(Dispatchers.IO) + private val scope = MainScope() + private lateinit var musicService: MusicService + private val context = reactContext +- ++ private var positionUpdateJob:Job? = null; ++ @Volatile ++ private var isPositionTrackingStarted: Boolean = false ++ ++ private val mmkv:MMKV? by lazy { ++ runBlocking(Dispatchers.IO) { ++ try { ++ MMKV.initialize(context) ++ MMKV.mmkvWithID("progress_storage") ++ } catch (e: Exception) { ++ // MMKV initialization failed, log but don't crash ++ android.util.Log.e("MusicModule", "Failed to initialize MMKV: ${e.message}", e) ++ null ++ } ++ } ++ } + @Nonnull + override fun getName(): String { + return NAME +@@ -56,6 +75,49 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + companion object { + const val NAME = "TrackPlayer" + } ++ @Volatile ++ private var lastPosition: Double = -1.0; ++ ++ ++ private fun stopPositionTracking(){ ++ positionUpdateJob?.cancel() ++ positionUpdateJob=null ++ isPositionTrackingStarted = false ++ } ++ private fun startPositionTracking(){ ++ // Only start if not already started and service is bound ++ if (isPositionTrackingStarted || !isServiceBound) return ++ ++ // Cancel any existing job (defensive cleanup) and set flag ++ positionUpdateJob?.cancel() ++ isPositionTrackingStarted = true ++ positionUpdateJob = backgroundScope.launch { ++ while(isServiceBound && isActive){ ++ try { ++ val currentPosition = withContext(Dispatchers.Main){ ++ musicService.getPositionInSeconds() ++ } ++ // Only save if position changed significantly AND is not 0 (to prevent overwriting saved position during reset) ++ if(Math.abs(currentPosition-lastPosition)>0.5 && currentPosition > 0.5){ ++ try { ++ mmkv?.let { ++ it.encode("player-key",currentPosition) ++ lastPosition=currentPosition ++ } ++ } catch (e: Exception) { ++ // MMKV write failed, log but don't crash ++ android.util.Log.w("MusicModule", "Failed to save position to MMKV: ${e.message}", e) ++ } ++ } ++ } catch (e: Exception) { ++ // Position tracking failed, log but don't crash ++ android.util.Log.w("MusicModule", "Position tracking error: ${e.message}", e) ++ } ++ delay(1000); ++ } ++ } ++ } ++ + + override fun addListener(eventType: String) { + // No implementation needed for TurboModule +@@ -79,6 +141,8 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + musicService = binder.service + musicService.setupPlayer(playerOptions) + playerSetUpPromise?.resolve(null) ++ ++ + } + + isServiceBound = true +@@ -91,6 +155,7 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + override fun onServiceDisconnected(name: ComponentName) { + launchInScope { + isServiceBound = false ++ stopPositionTracking() + } + } + +@@ -221,7 +286,7 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + val sessionToken = + SessionToken(context, ComponentName(context, MusicService::class.java)) + val browserFuture = MediaBrowser.Builder(context, sessionToken).buildAsync() +- // browser = browserFuture.get() ++ + } + } catch (exception: Exception) { + Timber.w(exception, "Could not initialize service") +@@ -384,6 +449,9 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + override fun reset(callback: Promise) = launchInScope { + if (verifyServiceBoundOrReject(callback)) return@launchInScope + ++ // Stop position tracking to prevent it from overwriting saved position with 0 ++ stopPositionTracking() ++ + musicService.stop() + delay(300) // Allow playback to stop + musicService.clear() +@@ -413,6 +481,7 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + } + + override fun seekTo(seconds: Double, callback: Promise) = launchInScope { ++ android.util.Log.d("Seek",seconds.toString()) + if (verifyServiceBoundOrReject(callback)) return@launchInScope + + musicService.seekTo(seconds.toFloat()) +@@ -531,7 +600,14 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec + } + + override fun getProgress(callback: Promise) = launchInScope { ++ android.util.Log.d("Module","violettttttttttt"); + if (verifyServiceBoundOrReject(callback)) return@launchInScope ++ ++ // Start position tracking on first getProgress call (efficient single check) ++ if (!isPositionTrackingStarted) { ++ startPositionTracking() ++ } ++ + val bundle = Bundle() + bundle.putDouble("duration", musicService.getDurationInSeconds()); + bundle.putDouble("position", musicService.getPositionInSeconds()); +diff --git a/node_modules/react-native-track-player/ios/MMKVHelper.h b/node_modules/react-native-track-player/ios/MMKVHelper.h +new file mode 100644 +index 0000000..3f79989 +--- /dev/null ++++ b/node_modules/react-native-track-player/ios/MMKVHelper.h +@@ -0,0 +1,21 @@ ++// ++// MMKVHelper.h ++// react-native-track-player ++// ++// Helper class to access MMKV storage for position tracking ++// ++ ++#import ++ ++NS_ASSUME_NONNULL_BEGIN ++ ++@interface MMKVHelper : NSObject ++ +++ (instancetype)sharedInstance; ++- (double)getPosition; ++- (void)setPosition:(double)position; ++ ++@end ++ ++NS_ASSUME_NONNULL_END ++ +diff --git a/node_modules/react-native-track-player/ios/MMKVHelper.mm b/node_modules/react-native-track-player/ios/MMKVHelper.mm +new file mode 100644 +index 0000000..2760fec +--- /dev/null ++++ b/node_modules/react-native-track-player/ios/MMKVHelper.mm +@@ -0,0 +1,90 @@ ++// ++// MMKVHelper.mm ++// react-native-track-player ++// ++// Helper class to access MMKV storage for position tracking ++// ++ ++#import "MMKVHelper.h" ++#include ++#include ++ ++@interface MMKVHelper () { ++ mmkv::MMKV *_mmkv; ++} ++@end ++ ++@implementation MMKVHelper ++ +++ (instancetype)sharedInstance { ++ static MMKVHelper *instance = nil; ++ static dispatch_once_t onceToken; ++ dispatch_once(&onceToken, ^{ ++ instance = [[MMKVHelper alloc] init]; ++ }); ++ return instance; ++} ++ ++- (instancetype)init { ++ self = [super init]; ++ if (self) { ++ @try { ++ // Initialize MMKV with the same ID as Android: "progress_storage" ++ std::string mmkvID = "progress_storage"; ++ _mmkv = mmkv::MMKV::mmkvWithID(mmkvID); ++ } @catch (NSException *exception) { ++ // MMKV initialization failed, log but don't crash ++ NSLog(@"MMKVHelper: Failed to initialize MMKV: %@ - %@", exception.name, exception.reason ?: @"Unknown"); ++ _mmkv = nullptr; ++ } @catch (...) { ++ // Catch any C++ exceptions ++ NSLog(@"MMKVHelper: Failed to initialize MMKV: Unknown C++ exception"); ++ _mmkv = nullptr; ++ } ++ } ++ return self; ++} ++ ++- (double)getPosition { ++ if (!_mmkv) { ++ return 0.0; ++ } ++ ++ @try { ++ std::string key = "player-key"; ++ return _mmkv->getDouble(key, 0.0); ++ } @catch (NSException *exception) { ++ // MMKV read failed, log but don't crash ++ NSLog(@"MMKVHelper: Failed to get position: %@ - %@", exception.name, exception.reason ?: @"Unknown"); ++ return 0.0; ++ } @catch (...) { ++ // Catch any C++ exceptions ++ NSLog(@"MMKVHelper: Failed to get position: Unknown C++ exception"); ++ return 0.0; ++ } ++} ++ ++- (void)setPosition:(double)position { ++ if (!_mmkv) { ++ return; ++ } ++ ++ @try { ++ std::string key = "player-key"; ++ _mmkv->set(position, key); ++ } @catch (NSException *exception) { ++ // MMKV write failed, log but don't crash ++ NSLog(@"MMKVHelper: Failed to set position: %@ - %@", exception.name, exception.reason ?: @"Unknown"); ++ } @catch (...) { ++ // Catch any C++ exceptions ++ NSLog(@"MMKVHelper: Failed to set position: Unknown C++ exception"); ++ } ++} ++ ++- (void)dealloc { ++ // MMKV instances are managed by MMKV itself, no need to release ++ _mmkv = nullptr; ++} ++ ++@end ++ +diff --git a/node_modules/react-native-track-player/ios/TrackPlayer-Bridging-Header.h b/node_modules/react-native-track-player/ios/TrackPlayer-Bridging-Header.h +index b5564a4..e1ac6af 100644 +--- a/node_modules/react-native-track-player/ios/TrackPlayer-Bridging-Header.h ++++ b/node_modules/react-native-track-player/ios/TrackPlayer-Bridging-Header.h +@@ -1,3 +1,4 @@ + // TrackPlayer-Bridging-Header.h + #import + #import ++#import "MMKVHelper.h" +\ No newline at end of file +diff --git a/node_modules/react-native-track-player/ios/TrackPlayer.swift b/node_modules/react-native-track-player/ios/TrackPlayer.swift +index 92f2c54..9e98171 100644 +--- a/node_modules/react-native-track-player/ios/TrackPlayer.swift ++++ b/node_modules/react-native-track-player/ios/TrackPlayer.swift +@@ -28,6 +28,61 @@ public class NativeTrackPlayerImpl: NSObject, AudioSessionControllerDelegate { + private var sessionCategoryPolicy: AVAudioSession.RouteSharingPolicy = .default + private var sessionCategoryOptions: AVAudioSession.CategoryOptions = [] + ++ // MARK: - Position Tracking ++ ++ private var positionTrackingTimer: DispatchSourceTimer? ++ private var isPositionTrackingStarted: Bool = false ++ private var lastPosition: Double = -1.0 ++ private let positionTrackingQueue = DispatchQueue(label: "com.trackplayer.positionTracking", qos: .utility) ++ ++ // Access MMKV helper through Objective-C++ bridge using dynamic lookup ++ private lazy var mmkvHelper: AnyObject = { ++ let className = "MMKVHelper" ++ guard let helperClass = NSClassFromString(className) as? NSObject.Type else { ++ fatalError("MMKVHelper class not found") ++ } ++ return helperClass.perform(NSSelectorFromString("sharedInstance"))?.takeUnretainedValue() as AnyObject ++ }() ++ ++ private func getMMKVPosition() -> Double { ++ // Safely get position from MMKV, return 0.0 on any failure ++ // Note: Swift cannot catch Objective-C exceptions, but perform is safe ++ guard let helper = mmkvHelper as? NSObject else { ++ print("MMKVHelper: Helper not available") ++ return 0.0 ++ } ++ ++ guard helper.responds(to: NSSelectorFromString("getPosition")) else { ++ print("MMKVHelper: getPosition method not available") ++ return 0.0 ++ } ++ ++ // perform is safe and won't crash, but we check the result ++ if let result = helper.perform(NSSelectorFromString("getPosition"))?.takeUnretainedValue() as? NSNumber { ++ return result.doubleValue ++ } ++ ++ return 0.0 ++ } ++ ++ private func setMMKVPosition(_ position: Double) { ++ // Safely set position in MMKV, fail silently on any error ++ // Note: Swift cannot catch Objective-C exceptions, but perform is safe ++ guard let helper = mmkvHelper as? NSObject else { ++ print("MMKVHelper: Helper not available") ++ return ++ } ++ ++ let selector = NSSelectorFromString("setPosition:") ++ guard helper.responds(to: selector) else { ++ print("MMKVHelper: setPosition: method not available") ++ return ++ } ++ ++ // perform is safe and won't crash the app ++ helper.perform(selector, with: NSNumber(value: position)) ++ } ++ + // MARK: - Lifecycle Methods + + public override init() { +@@ -45,9 +100,45 @@ public class NativeTrackPlayerImpl: NSObject, AudioSessionControllerDelegate { + } + + deinit { ++ stopPositionTracking() + reset(resolve: { _ in }, reject: { _, _, _ in }) + } + ++ // MARK: - Position Tracking Methods ++ ++ private func stopPositionTracking() { ++ positionTrackingTimer?.cancel() ++ positionTrackingTimer = nil ++ isPositionTrackingStarted = false ++ } ++ ++ private func startPositionTracking() { ++ // Only start if not already started ++ guard !isPositionTrackingStarted else { return } ++ ++ stopPositionTracking() ++ isPositionTrackingStarted = true ++ ++ // Create timer on background queue for maximum performance ++ positionTrackingTimer = DispatchSource.makeTimerSource(queue: positionTrackingQueue) ++ positionTrackingTimer?.schedule(deadline: .now(), repeating: 1.0) ++ positionTrackingTimer?.setEventHandler { [weak self] in ++ guard let self = self, self.isPositionTrackingStarted else { return } ++ ++ // Get current position on main queue (player access must be on main thread) ++ let currentPosition = DispatchQueue.main.sync { ++ self.player.currentTime ++ } ++ ++ // Only save if position changed significantly AND is not 0 (to prevent overwriting saved position during reset) ++ if abs(currentPosition - self.lastPosition) > 0.5 && currentPosition > 0.5 { ++ self.setMMKVPosition(currentPosition) ++ self.lastPosition = currentPosition ++ } ++ } ++ positionTrackingTimer?.resume() ++ } ++ + // MARK: - RCTEventEmitter + private func emit(event: EventType, body: Any? = nil) { + delegate?.sendEvent(name: event.rawValue, body: body) +@@ -459,6 +550,9 @@ public class NativeTrackPlayerImpl: NSObject, AudioSessionControllerDelegate { + public func reset(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + if (rejectWhenNotInitialized(reject: reject)) { return } + ++ // Stop position tracking to prevent it from overwriting saved position with 0 ++ stopPositionTracking() ++ + player.stop() + player.clear() + resolve(NSNull()) +@@ -639,6 +733,12 @@ public class NativeTrackPlayerImpl: NSObject, AudioSessionControllerDelegate { + @objc + public func getProgress(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + if (rejectWhenNotInitialized(reject: reject)) { return } ++ ++ // Start position tracking on first getProgress call (efficient single check) ++ if !isPositionTrackingStarted { ++ startPositionTracking() ++ } ++ + resolve([ + "position": player.currentTime, + "duration": player.duration, +diff --git a/node_modules/react-native-track-player/react-native-track-player.podspec b/node_modules/react-native-track-player/react-native-track-player.podspec +index ce4fee0..14ad2a2 100644 +--- a/node_modules/react-native-track-player/react-native-track-player.podspec ++++ b/node_modules/react-native-track-player/react-native-track-player.podspec +@@ -19,6 +19,7 @@ Pod::Spec.new do |s| + s.swift_version = "4.2" + + s.dependency "SwiftAudioEx", "1.1.0" ++ s.dependency "MMKVCore", "2.2.4" + + # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. + # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. diff --git a/src/providers/Player/functions/initialization.ts b/src/providers/Player/functions/initialization.ts index 10a9b05b..24b115d3 100644 --- a/src/providers/Player/functions/initialization.ts +++ b/src/providers/Player/functions/initialization.ts @@ -4,6 +4,7 @@ import TrackPlayer, { RepeatMode } from 'react-native-track-player' import { usePlayerQueueStore } from '../../../stores/player/queue' import { queryClient } from '../../../constants/query-client' import { REPEAT_MODE_QUERY_KEY } from '../constants/query-keys' +import { createMMKV } from 'react-native-mmkv' export default async function Initialize() { const { @@ -13,6 +14,11 @@ export default async function Initialize() { repeatMode, } = usePlayerQueueStore.getState() + // Read saved position BEFORE reset() to prevent it from being cleared + const progressStorage = createMMKV({ id: 'progress_storage' }) + const savedPosition = progressStorage.getNumber('player-key') ?? 0 + console.log('savedPosition before reset', savedPosition) + const storedPlayQueue = persistedQueue.length > 0 ? persistedQueue : getPlayQueue() const storedIndex = persistedIndex ?? getActiveIndex() const storedTrack = persistedTrack ?? getCurrentTrack() @@ -35,4 +41,14 @@ export default async function Initialize() { const restoredRepeatMode = repeatMode ?? RepeatMode.Off await TrackPlayer.setRepeatMode(restoredRepeatMode) queryClient.setQueryData(REPEAT_MODE_QUERY_KEY, restoredRepeatMode) + + // Restore saved playback position after queue is loaded + if (savedPosition > 0) { + try { + await TrackPlayer.seekTo(savedPosition) + console.log('Restored playback position:', savedPosition) + } catch (error) { + console.warn('Failed to restore playback position:', error) + } + } }