@rntp/player 5.0.0-beta.1 → 5.0.0-beta.3
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/RNTPPlayer.podspec +1 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +61 -1
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +148 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +11 -0
- package/ios/CarPlay/BrowseTreeStore.swift +40 -0
- package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +254 -0
- package/ios/TrackPlayer.swift +135 -1
- package/ios/TrackPlayerBridge.mm +9 -0
- package/ios/models/EmitEvent.swift +10 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +58 -5
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/events/SleepTimerTriggered.js +2 -0
- package/lib/commonjs/events/SleepTimerTriggered.js.map +1 -0
- package/lib/commonjs/events/index.js +13 -0
- package/lib/commonjs/events/index.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +54 -5
- package/lib/module/audio.js.map +1 -1
- package/lib/module/events/SleepTimerTriggered.js +2 -0
- package/lib/module/events/SleepTimerTriggered.js.map +1 -0
- package/lib/module/events/index.js +2 -0
- package/lib/module/events/index.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +4 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +46 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/events/SleepTimerTriggered.d.ts +5 -0
- package/lib/typescript/src/events/SleepTimerTriggered.d.ts.map +1 -0
- package/lib/typescript/src/events/index.d.ts +5 -1
- package/lib/typescript/src/events/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeTrackPlayer.ts +6 -0
- package/src/audio.ts +67 -6
- package/src/events/SleepTimerTriggered.ts +4 -0
- package/src/events/index.ts +4 -0
package/RNTPPlayer.podspec
CHANGED
|
@@ -481,7 +481,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
|
|
|
481
481
|
// region Progress Sync
|
|
482
482
|
|
|
483
483
|
@ReactMethod
|
|
484
|
-
fun updateProgressSyncHeaders(headers: ReadableMap) {
|
|
484
|
+
override fun updateProgressSyncHeaders(headers: ReadableMap) {
|
|
485
485
|
val currentConfig = PlayerConfig().load(context)
|
|
486
486
|
val newHeaders = mutableMapOf<String, String>()
|
|
487
487
|
val iterator = headers.keySetIterator()
|
|
@@ -502,6 +502,66 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
|
|
|
502
502
|
|
|
503
503
|
// endregion
|
|
504
504
|
|
|
505
|
+
// region Sleep Timer
|
|
506
|
+
|
|
507
|
+
@ReactMethod
|
|
508
|
+
override fun sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
|
|
509
|
+
controller.run { mc ->
|
|
510
|
+
val args = Bundle().apply {
|
|
511
|
+
putDouble("seconds", seconds)
|
|
512
|
+
putDouble("fadeOutSeconds", fadeOutSeconds)
|
|
513
|
+
}
|
|
514
|
+
mc?.sendCustomCommand(
|
|
515
|
+
SessionCommand(TrackPlayerPlaybackService.COMMAND_SLEEP_AFTER_TIME, Bundle.EMPTY),
|
|
516
|
+
args
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
@ReactMethod
|
|
522
|
+
override fun sleepAfterMediaItemAtIndex(index: Double) {
|
|
523
|
+
controller.run { mc ->
|
|
524
|
+
val args = Bundle().apply { putInt("index", index.toInt()) }
|
|
525
|
+
mc?.sendCustomCommand(
|
|
526
|
+
SessionCommand(TrackPlayerPlaybackService.COMMAND_SLEEP_AFTER_MEDIA_ITEM, Bundle.EMPTY),
|
|
527
|
+
args
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
533
|
+
override fun getSleepTimer(): WritableMap? {
|
|
534
|
+
val prefs = context.getSharedPreferences(PLAYER_PREFS_NAME, android.content.Context.MODE_PRIVATE)
|
|
535
|
+
val jsonStr = prefs.getString(TrackPlayerPlaybackService.SLEEP_TIMER_STATE_KEY, null) ?: return null
|
|
536
|
+
return try {
|
|
537
|
+
val json = JSONObject(jsonStr)
|
|
538
|
+
val type = json.getString("type")
|
|
539
|
+
val map = Arguments.createMap()
|
|
540
|
+
map.putString("type", type)
|
|
541
|
+
if (type == "time") {
|
|
542
|
+
map.putDouble("remainingSeconds", json.getDouble("remainingSeconds"))
|
|
543
|
+
map.putDouble("fadeOutSeconds", json.getDouble("fadeOutSeconds"))
|
|
544
|
+
} else {
|
|
545
|
+
map.putInt("index", json.getInt("index"))
|
|
546
|
+
}
|
|
547
|
+
map
|
|
548
|
+
} catch (e: Exception) {
|
|
549
|
+
null
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
@ReactMethod
|
|
554
|
+
override fun cancelSleepTimer() {
|
|
555
|
+
controller.run { mc ->
|
|
556
|
+
mc?.sendCustomCommand(
|
|
557
|
+
SessionCommand(TrackPlayerPlaybackService.COMMAND_CANCEL_SLEEP_TIMER, Bundle.EMPTY),
|
|
558
|
+
Bundle.EMPTY
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// endregion
|
|
564
|
+
|
|
505
565
|
// region Destroy
|
|
506
566
|
|
|
507
567
|
@ReactMethod
|
|
@@ -56,6 +56,7 @@ import com.doublesymmetry.trackplayer.models.WakeMode
|
|
|
56
56
|
import android.os.Handler
|
|
57
57
|
import android.os.Looper
|
|
58
58
|
import com.doublesymmetry.trackplayer.models.PlaybackProgressUpdatedEvent
|
|
59
|
+
import com.doublesymmetry.trackplayer.models.SleepTimerTriggeredEvent
|
|
59
60
|
import org.json.JSONObject
|
|
60
61
|
|
|
61
62
|
class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.MediaLibrarySession.Callback {
|
|
@@ -68,6 +69,14 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
68
69
|
private var progressSyncTimer: java.util.Timer? = null
|
|
69
70
|
private var progressSyncExecutor: java.util.concurrent.ExecutorService? = null
|
|
70
71
|
|
|
72
|
+
private var sleepTimerType: String? = null // "time" or "mediaItem"
|
|
73
|
+
private var sleepTimerRemainingSeconds: Double = 0.0
|
|
74
|
+
private var sleepTimerFadeOutSeconds: Double = 0.0
|
|
75
|
+
private var sleepTimerTargetIndex: Int? = null
|
|
76
|
+
private var sleepTimer: java.util.Timer? = null
|
|
77
|
+
private var sleepTimerPreFadeVolume: Float? = null
|
|
78
|
+
private var sleepTimerPreviousIndex: Int? = null
|
|
79
|
+
|
|
71
80
|
@OptIn(UnstableApi::class)
|
|
72
81
|
override fun onCreate() {
|
|
73
82
|
super.onCreate()
|
|
@@ -136,6 +145,26 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
136
145
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
137
146
|
if (isPlaying) startProgressSyncTimer() else stopProgressSyncTimer(fireFinalTick = true)
|
|
138
147
|
}
|
|
148
|
+
|
|
149
|
+
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
150
|
+
val currentIndex = activePlayer?.currentMediaItemIndex ?: 0
|
|
151
|
+
if (sleepTimerType == "mediaItem") {
|
|
152
|
+
val targetIndex = sleepTimerTargetIndex
|
|
153
|
+
if (targetIndex != null && sleepTimerPreviousIndex == targetIndex && currentIndex != targetIndex) {
|
|
154
|
+
this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("mediaItem"))
|
|
155
|
+
cancelSleepTimerInternal(restoreVolume = false)
|
|
156
|
+
// Defer pause to next run loop — calling during transition gets overridden
|
|
157
|
+
Handler(Looper.getMainLooper()).post { activePlayer?.pause() }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
sleepTimerPreviousIndex = currentIndex
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override fun onTimelineChanged(timeline: androidx.media3.common.Timeline, reason: Int) {
|
|
164
|
+
if (sleepTimerType == "mediaItem" && (activePlayer?.mediaItemCount ?: 0) == 0) {
|
|
165
|
+
cancelSleepTimerInternal(restoreVolume = false)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
139
168
|
})
|
|
140
169
|
val player = createForwardingPlayer(activePlayer, config)
|
|
141
170
|
|
|
@@ -275,6 +304,7 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
275
304
|
simpleCache?.release()
|
|
276
305
|
simpleCache = null
|
|
277
306
|
sharedCache = null
|
|
307
|
+
cancelSleepTimerInternal(restoreVolume = false)
|
|
278
308
|
stopProgressSyncTimer(fireFinalTick = false)
|
|
279
309
|
progressSyncExecutor?.shutdown()
|
|
280
310
|
progressSyncExecutor = null
|
|
@@ -355,6 +385,37 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
355
385
|
activePlayer?.seekToPreviousMediaItem()
|
|
356
386
|
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
357
387
|
}
|
|
388
|
+
COMMAND_SLEEP_AFTER_TIME -> {
|
|
389
|
+
val seconds = args.getDouble("seconds")
|
|
390
|
+
val fadeOutSeconds = args.getDouble("fadeOutSeconds")
|
|
391
|
+
cancelSleepTimerInternal(restoreVolume = true)
|
|
392
|
+
sleepTimerType = "time"
|
|
393
|
+
sleepTimerRemainingSeconds = seconds
|
|
394
|
+
sleepTimerFadeOutSeconds = fadeOutSeconds.coerceAtMost(seconds)
|
|
395
|
+
persistSleepTimerState()
|
|
396
|
+
|
|
397
|
+
if (seconds <= 0) {
|
|
398
|
+
activePlayer?.pause()
|
|
399
|
+
this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("time"))
|
|
400
|
+
cancelSleepTimerInternal(restoreVolume = true)
|
|
401
|
+
} else {
|
|
402
|
+
startSleepCountdownTimer()
|
|
403
|
+
}
|
|
404
|
+
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
405
|
+
}
|
|
406
|
+
COMMAND_SLEEP_AFTER_MEDIA_ITEM -> {
|
|
407
|
+
val index = args.getInt("index")
|
|
408
|
+
cancelSleepTimerInternal(restoreVolume = true)
|
|
409
|
+
sleepTimerType = "mediaItem"
|
|
410
|
+
sleepTimerTargetIndex = index
|
|
411
|
+
sleepTimerPreviousIndex = activePlayer?.currentMediaItemIndex
|
|
412
|
+
persistSleepTimerState()
|
|
413
|
+
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
414
|
+
}
|
|
415
|
+
COMMAND_CANCEL_SLEEP_TIMER -> {
|
|
416
|
+
cancelSleepTimerInternal(restoreVolume = true)
|
|
417
|
+
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
418
|
+
}
|
|
358
419
|
}
|
|
359
420
|
return super.onCustomCommand(session, controller, customCommand, args)
|
|
360
421
|
}
|
|
@@ -475,6 +536,9 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
475
536
|
.add(SessionCommand(COMMAND_SEEK_TO_NEXT, Bundle.EMPTY))
|
|
476
537
|
.add(SessionCommand(COMMAND_SEEK_TO_PREVIOUS, Bundle.EMPTY))
|
|
477
538
|
.add(SessionCommand(COMMAND_UPDATE_PROGRESS_SYNC_HEADERS, Bundle.EMPTY))
|
|
539
|
+
.add(SessionCommand(COMMAND_SLEEP_AFTER_TIME, Bundle.EMPTY))
|
|
540
|
+
.add(SessionCommand(COMMAND_SLEEP_AFTER_MEDIA_ITEM, Bundle.EMPTY))
|
|
541
|
+
.add(SessionCommand(COMMAND_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
|
|
478
542
|
.build()
|
|
479
543
|
|
|
480
544
|
// endregion
|
|
@@ -554,6 +618,86 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
554
618
|
|
|
555
619
|
// endregion
|
|
556
620
|
|
|
621
|
+
// region Sleep Timer
|
|
622
|
+
|
|
623
|
+
private fun cancelSleepTimerInternal(restoreVolume: Boolean) {
|
|
624
|
+
sleepTimer?.cancel()
|
|
625
|
+
sleepTimer = null
|
|
626
|
+
if (restoreVolume) {
|
|
627
|
+
sleepTimerPreFadeVolume?.let { activePlayer?.volume = it }
|
|
628
|
+
}
|
|
629
|
+
sleepTimerPreFadeVolume = null
|
|
630
|
+
sleepTimerType = null
|
|
631
|
+
sleepTimerRemainingSeconds = 0.0
|
|
632
|
+
sleepTimerFadeOutSeconds = 0.0
|
|
633
|
+
sleepTimerTargetIndex = null
|
|
634
|
+
sleepTimerPreviousIndex = null
|
|
635
|
+
persistSleepTimerState()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private fun onSleepTimerTick() {
|
|
639
|
+
sleepTimerRemainingSeconds -= 1.0
|
|
640
|
+
persistSleepTimerState()
|
|
641
|
+
|
|
642
|
+
// Handle fade-out (before zero-check so final tick sets volume to 0)
|
|
643
|
+
if (sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds) {
|
|
644
|
+
if (sleepTimerPreFadeVolume == null) {
|
|
645
|
+
sleepTimerPreFadeVolume = activePlayer?.volume ?: 1f
|
|
646
|
+
}
|
|
647
|
+
val progress = maxOf(0.0, sleepTimerRemainingSeconds / sleepTimerFadeOutSeconds).toFloat()
|
|
648
|
+
activePlayer?.volume = (sleepTimerPreFadeVolume ?: 1f) * progress
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (sleepTimerRemainingSeconds <= 0) {
|
|
652
|
+
sleepTimerRemainingSeconds = 0.0
|
|
653
|
+
activePlayer?.pause()
|
|
654
|
+
this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("time"))
|
|
655
|
+
// Restore volume after pausing so next playback isn't muted
|
|
656
|
+
cancelSleepTimerInternal(restoreVolume = true)
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private fun startSleepCountdownTimer() {
|
|
662
|
+
sleepTimer?.cancel()
|
|
663
|
+
val handler = Handler(Looper.getMainLooper())
|
|
664
|
+
sleepTimer = java.util.Timer().apply {
|
|
665
|
+
scheduleAtFixedRate(object : java.util.TimerTask() {
|
|
666
|
+
override fun run() {
|
|
667
|
+
handler.post { onSleepTimerTick() }
|
|
668
|
+
}
|
|
669
|
+
}, 1000L, 1000L)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private fun pauseSleepCountdownTimer() {
|
|
674
|
+
sleepTimer?.cancel()
|
|
675
|
+
sleepTimer = null
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private fun persistSleepTimerState() {
|
|
679
|
+
val prefs = getSharedPreferences(TrackPlayerModule.PLAYER_PREFS_NAME, MODE_PRIVATE)
|
|
680
|
+
val editor = prefs.edit()
|
|
681
|
+
val type = sleepTimerType
|
|
682
|
+
if (type == null) {
|
|
683
|
+
editor.remove(SLEEP_TIMER_STATE_KEY)
|
|
684
|
+
} else {
|
|
685
|
+
val json = JSONObject().apply {
|
|
686
|
+
put("type", type)
|
|
687
|
+
if (type == "time") {
|
|
688
|
+
put("remainingSeconds", sleepTimerRemainingSeconds)
|
|
689
|
+
put("fadeOutSeconds", sleepTimerFadeOutSeconds)
|
|
690
|
+
} else {
|
|
691
|
+
put("index", sleepTimerTargetIndex ?: 0)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
editor.putString(SLEEP_TIMER_STATE_KEY, json.toString())
|
|
695
|
+
}
|
|
696
|
+
editor.apply()
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// endregion
|
|
700
|
+
|
|
557
701
|
// region Progress Sync
|
|
558
702
|
|
|
559
703
|
private fun startProgressSyncTimer() {
|
|
@@ -651,6 +795,10 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
|
|
|
651
795
|
const val COMMAND_SEEK_TO_PREVIOUS = "trackplayer.seek_to_previous"
|
|
652
796
|
const val COMMAND_UPDATE_PROGRESS_SYNC_HEADERS = "trackplayer.update_progress_sync_headers"
|
|
653
797
|
const val PROGRESS_SYNC_SAVED_KEY = "progress_sync_saved"
|
|
798
|
+
const val COMMAND_SLEEP_AFTER_TIME = "trackplayer.sleep_after_time"
|
|
799
|
+
const val COMMAND_SLEEP_AFTER_MEDIA_ITEM = "trackplayer.sleep_after_media_item"
|
|
800
|
+
const val COMMAND_CANCEL_SLEEP_TIMER = "trackplayer.cancel_sleep_timer"
|
|
801
|
+
const val SLEEP_TIMER_STATE_KEY = "sleep_timer_state"
|
|
654
802
|
|
|
655
803
|
@Volatile
|
|
656
804
|
var sharedCache: SimpleCache? = null
|
|
@@ -21,6 +21,7 @@ enum class EmitEventType(val value: String) {
|
|
|
21
21
|
REMOTE_SKIP_FORWARD("event.remote-skip-forward"),
|
|
22
22
|
REMOTE_SKIP_BACKWARD("event.remote-skip-backward"),
|
|
23
23
|
PLAYBACK_PROGRESS_UPDATED("event.playback-progress-updated"),
|
|
24
|
+
SLEEP_TIMER_TRIGGERED("event.sleep-timer-triggered"),
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
interface EmitEvent {
|
|
@@ -116,3 +117,13 @@ data class PlaybackProgressUpdatedEvent(
|
|
|
116
117
|
)
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
data class SleepTimerTriggeredEvent(
|
|
122
|
+
val sleepType: String,
|
|
123
|
+
): EmitEvent {
|
|
124
|
+
override val type = EmitEventType.SLEEP_TIMER_TRIGGERED
|
|
125
|
+
|
|
126
|
+
override fun pairs(): Array<Pair<String, Any>> {
|
|
127
|
+
return arrayOf("type" to sleepType)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright (c) Double Symmetry GmbH
|
|
3
|
+
// Commercial use requires a license. See https://rntp.dev/pricing
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import Foundation
|
|
7
|
+
|
|
8
|
+
class BrowseTreeStore {
|
|
9
|
+
static let shared = BrowseTreeStore()
|
|
10
|
+
|
|
11
|
+
static let didChangeNotification = Notification.Name("RNTPBrowseTreeDidChange")
|
|
12
|
+
static let nowPlayingChangedNotification = Notification.Name("RNTPNowPlayingChanged")
|
|
13
|
+
|
|
14
|
+
/// The current browse tree categories as raw dictionaries from JS.
|
|
15
|
+
private(set) var categories: [[String: Any]] = []
|
|
16
|
+
|
|
17
|
+
/// Reference to the AudioPlayer, set during setupPlayer().
|
|
18
|
+
weak var player: AudioPlayer?
|
|
19
|
+
|
|
20
|
+
/// The mediaId of the currently playing item, if any.
|
|
21
|
+
private(set) var currentMediaId: String?
|
|
22
|
+
|
|
23
|
+
private init() {}
|
|
24
|
+
|
|
25
|
+
func updateNowPlaying(mediaId: String?) {
|
|
26
|
+
currentMediaId = mediaId
|
|
27
|
+
NotificationCenter.default.post(name: Self.nowPlayingChangedNotification, object: nil)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func update(categories: [[String: Any]]) {
|
|
31
|
+
self.categories = categories
|
|
32
|
+
NotificationCenter.default.post(name: Self.didChangeNotification, object: nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func clear() {
|
|
36
|
+
self.categories = []
|
|
37
|
+
self.player = nil
|
|
38
|
+
NotificationCenter.default.post(name: Self.didChangeNotification, object: nil)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright (c) Double Symmetry GmbH
|
|
3
|
+
// Commercial use requires a license. See https://rntp.dev/pricing
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import CarPlay
|
|
7
|
+
import UIKit
|
|
8
|
+
|
|
9
|
+
@objc(RNTPCarPlaySceneDelegate)
|
|
10
|
+
class RNTPCarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|
11
|
+
|
|
12
|
+
private var interfaceController: CPInterfaceController?
|
|
13
|
+
private let imageCache = NSCache<NSString, UIImage>()
|
|
14
|
+
private var notificationObserver: NSObjectProtocol?
|
|
15
|
+
private var nowPlayingObserver: NSObjectProtocol?
|
|
16
|
+
/// All track list items keyed by mediaId, for updating isPlaying state.
|
|
17
|
+
private var listItemsByMediaId: [String: [CPListItem]] = [:]
|
|
18
|
+
|
|
19
|
+
// MARK: - CPTemplateApplicationSceneDelegate
|
|
20
|
+
|
|
21
|
+
func templateApplicationScene(
|
|
22
|
+
_ templateApplicationScene: CPTemplateApplicationScene,
|
|
23
|
+
didConnect interfaceController: CPInterfaceController
|
|
24
|
+
) {
|
|
25
|
+
self.interfaceController = interfaceController
|
|
26
|
+
rebuildTemplates()
|
|
27
|
+
|
|
28
|
+
notificationObserver = NotificationCenter.default.addObserver(
|
|
29
|
+
forName: BrowseTreeStore.didChangeNotification,
|
|
30
|
+
object: nil,
|
|
31
|
+
queue: .main
|
|
32
|
+
) { [weak self] _ in
|
|
33
|
+
self?.rebuildTemplates()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
nowPlayingObserver = NotificationCenter.default.addObserver(
|
|
37
|
+
forName: BrowseTreeStore.nowPlayingChangedNotification,
|
|
38
|
+
object: nil,
|
|
39
|
+
queue: .main
|
|
40
|
+
) { [weak self] _ in
|
|
41
|
+
self?.updateNowPlayingState()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func templateApplicationScene(
|
|
46
|
+
_ templateApplicationScene: CPTemplateApplicationScene,
|
|
47
|
+
didDisconnect interfaceController: CPInterfaceController
|
|
48
|
+
) {
|
|
49
|
+
if let observer = notificationObserver {
|
|
50
|
+
NotificationCenter.default.removeObserver(observer)
|
|
51
|
+
notificationObserver = nil
|
|
52
|
+
}
|
|
53
|
+
if let observer = nowPlayingObserver {
|
|
54
|
+
NotificationCenter.default.removeObserver(observer)
|
|
55
|
+
nowPlayingObserver = nil
|
|
56
|
+
}
|
|
57
|
+
listItemsByMediaId = [:]
|
|
58
|
+
self.interfaceController = nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: - Template Construction
|
|
62
|
+
|
|
63
|
+
private func rebuildTemplates() {
|
|
64
|
+
guard let interfaceController = interfaceController else { return }
|
|
65
|
+
listItemsByMediaId = [:]
|
|
66
|
+
|
|
67
|
+
let allCategories = BrowseTreeStore.shared.categories
|
|
68
|
+
// Filter out empty categories
|
|
69
|
+
let categories = allCategories.filter { cat in
|
|
70
|
+
guard let items = cat["items"] as? [[String: Any]] else { return false }
|
|
71
|
+
return !items.isEmpty
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
guard !categories.isEmpty else {
|
|
75
|
+
interfaceController.setRootTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if categories.count == 1 {
|
|
80
|
+
let list = buildListTemplate(for: categories[0], categoryIndex: 0)
|
|
81
|
+
interfaceController.setRootTemplate(list, animated: true, completion: nil)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let maxTabs = min(4, CPTabBarTemplate.maximumTabCount)
|
|
86
|
+
|
|
87
|
+
if categories.count <= maxTabs {
|
|
88
|
+
let tabs = categories.enumerated().map { (i, cat) in
|
|
89
|
+
buildListTemplate(for: cat, categoryIndex: i)
|
|
90
|
+
}
|
|
91
|
+
let tabBar = CPTabBarTemplate(templates: tabs)
|
|
92
|
+
interfaceController.setRootTemplate(tabBar, animated: true, completion: nil)
|
|
93
|
+
} else {
|
|
94
|
+
// First (maxTabs - 1) as direct tabs, rest under "More"
|
|
95
|
+
let directCount = maxTabs - 1
|
|
96
|
+
var tabs: [CPListTemplate] = []
|
|
97
|
+
for i in 0..<directCount {
|
|
98
|
+
tabs.append(buildListTemplate(for: categories[i], categoryIndex: i))
|
|
99
|
+
}
|
|
100
|
+
let moreTab = buildMoreTemplate(categories: Array(categories.dropFirst(directCount)),
|
|
101
|
+
startingCategoryIndex: directCount)
|
|
102
|
+
tabs.append(moreTab)
|
|
103
|
+
let tabBar = CPTabBarTemplate(templates: tabs)
|
|
104
|
+
interfaceController.setRootTemplate(tabBar, animated: true, completion: nil)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// MARK: - List Template Builders
|
|
109
|
+
|
|
110
|
+
private func buildListTemplate(for category: [String: Any], categoryIndex: Int) -> CPListTemplate {
|
|
111
|
+
let title = category["title"] as? String ?? "Untitled"
|
|
112
|
+
let items = category["items"] as? [[String: Any]] ?? []
|
|
113
|
+
|
|
114
|
+
let maxItems = CPListTemplate.maximumItemCount
|
|
115
|
+
let truncated = maxItems > 0 ? Array(items.prefix(maxItems)) : items
|
|
116
|
+
|
|
117
|
+
let listItems: [CPListItem] = truncated.enumerated().map { (itemIndex, item) in
|
|
118
|
+
buildListItem(item: item, categoryIndex: categoryIndex, itemIndex: itemIndex)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let section = CPListSection(items: listItems)
|
|
122
|
+
let template = CPListTemplate(title: title, sections: [section])
|
|
123
|
+
template.tabTitle = title
|
|
124
|
+
return template
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func buildMoreTemplate(categories: [[String: Any]], startingCategoryIndex: Int) -> CPListTemplate {
|
|
128
|
+
let listItems: [CPListItem] = categories.enumerated().map { (offset, cat) in
|
|
129
|
+
let catTitle = cat["title"] as? String ?? "Untitled"
|
|
130
|
+
let item = CPListItem(text: catTitle, detailText: nil)
|
|
131
|
+
let catIndex = startingCategoryIndex + offset
|
|
132
|
+
item.userInfo = ["type": "category", "categoryIndex": catIndex]
|
|
133
|
+
item.handler = { [weak self] _, completion in
|
|
134
|
+
self?.handleMoreCategorySelected(category: cat, categoryIndex: catIndex)
|
|
135
|
+
completion()
|
|
136
|
+
}
|
|
137
|
+
return item
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let section = CPListSection(items: listItems)
|
|
141
|
+
let template = CPListTemplate(title: "More", sections: [section])
|
|
142
|
+
template.tabTitle = "More"
|
|
143
|
+
template.tabImage = UIImage(systemName: "ellipsis.circle")
|
|
144
|
+
return template
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func handleMoreCategorySelected(category: [String: Any], categoryIndex: Int) {
|
|
148
|
+
let detail = buildListTemplate(for: category, categoryIndex: categoryIndex)
|
|
149
|
+
interfaceController?.pushTemplate(detail, animated: true, completion: nil)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MARK: - List Item Builder
|
|
153
|
+
|
|
154
|
+
private func buildListItem(item: [String: Any], categoryIndex: Int, itemIndex: Int) -> CPListItem {
|
|
155
|
+
let title = item["title"] as? String ?? "Untitled"
|
|
156
|
+
let artist = item["artist"] as? String
|
|
157
|
+
let listItem = CPListItem(text: title, detailText: artist)
|
|
158
|
+
|
|
159
|
+
listItem.userInfo = ["categoryIndex": categoryIndex, "itemIndex": itemIndex]
|
|
160
|
+
listItem.handler = { [weak self] _, completion in
|
|
161
|
+
self?.handleItemSelected(categoryIndex: categoryIndex, itemIndex: itemIndex)
|
|
162
|
+
completion()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Track by mediaId for now-playing state updates
|
|
166
|
+
if let mediaId = item["mediaId"] as? String {
|
|
167
|
+
listItemsByMediaId[mediaId, default: []].append(listItem)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Mark as playing if this is the current item
|
|
171
|
+
if let mediaId = item["mediaId"] as? String,
|
|
172
|
+
mediaId == BrowseTreeStore.shared.currentMediaId {
|
|
173
|
+
listItem.isPlaying = true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Load artwork async
|
|
177
|
+
if let artworkUrlString = item["artworkUrl"] as? String,
|
|
178
|
+
let artworkUrl = URL(string: artworkUrlString) {
|
|
179
|
+
loadImage(url: artworkUrl) { [weak listItem] image in
|
|
180
|
+
listItem?.setImage(image)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return listItem
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MARK: - Now Playing State
|
|
188
|
+
|
|
189
|
+
private func updateNowPlayingState() {
|
|
190
|
+
let currentMediaId = BrowseTreeStore.shared.currentMediaId
|
|
191
|
+
for (mediaId, items) in listItemsByMediaId {
|
|
192
|
+
let isPlaying = mediaId == currentMediaId
|
|
193
|
+
for item in items {
|
|
194
|
+
item.isPlaying = isPlaying
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// MARK: - Playback
|
|
200
|
+
|
|
201
|
+
private func handleItemSelected(categoryIndex: Int, itemIndex: Int) {
|
|
202
|
+
guard let player = BrowseTreeStore.shared.player else { return }
|
|
203
|
+
let categories = BrowseTreeStore.shared.categories
|
|
204
|
+
guard categoryIndex < categories.count,
|
|
205
|
+
let items = categories[categoryIndex]["items"] as? [[String: Any]] else { return }
|
|
206
|
+
|
|
207
|
+
let mediaItems = items.compactMap { MediaItem(data: $0) }
|
|
208
|
+
guard !mediaItems.isEmpty else { return }
|
|
209
|
+
|
|
210
|
+
player.clear()
|
|
211
|
+
player.add(items: mediaItems)
|
|
212
|
+
let idx = min(itemIndex, mediaItems.count - 1)
|
|
213
|
+
if idx > 0 {
|
|
214
|
+
player.skipTo(index: idx)
|
|
215
|
+
}
|
|
216
|
+
player.play()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// MARK: - Artwork
|
|
220
|
+
|
|
221
|
+
private func loadImage(url: URL, completion: @escaping (UIImage) -> Void) {
|
|
222
|
+
let key = url.absoluteString as NSString
|
|
223
|
+
if let cached = imageCache.object(forKey: key) {
|
|
224
|
+
completion(cached)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
|
229
|
+
guard let data = data, let image = UIImage(data: data) else { return }
|
|
230
|
+
|
|
231
|
+
// Scale to CarPlay max image size
|
|
232
|
+
let maxSize = CPListItem.maximumImageSize
|
|
233
|
+
let scaled = Self.scaleImage(image, to: maxSize)
|
|
234
|
+
|
|
235
|
+
self?.imageCache.setObject(scaled, forKey: key)
|
|
236
|
+
DispatchQueue.main.async {
|
|
237
|
+
completion(scaled)
|
|
238
|
+
}
|
|
239
|
+
}.resume()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private static func scaleImage(_ image: UIImage, to maxSize: CGSize) -> UIImage {
|
|
243
|
+
let widthRatio = maxSize.width / image.size.width
|
|
244
|
+
let heightRatio = maxSize.height / image.size.height
|
|
245
|
+
let ratio = min(widthRatio, heightRatio, 1.0) // Don't upscale
|
|
246
|
+
if ratio >= 1.0 { return image }
|
|
247
|
+
|
|
248
|
+
let newSize = CGSize(width: image.size.width * ratio, height: image.size.height * ratio)
|
|
249
|
+
let renderer = UIGraphicsImageRenderer(size: newSize)
|
|
250
|
+
return renderer.image { _ in
|
|
251
|
+
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|