@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.
Files changed (36) hide show
  1. package/RNTPPlayer.podspec +1 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +61 -1
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +148 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +11 -0
  5. package/ios/CarPlay/BrowseTreeStore.swift +40 -0
  6. package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +254 -0
  7. package/ios/TrackPlayer.swift +135 -1
  8. package/ios/TrackPlayerBridge.mm +9 -0
  9. package/ios/models/EmitEvent.swift +10 -0
  10. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  11. package/lib/commonjs/audio.js +58 -5
  12. package/lib/commonjs/audio.js.map +1 -1
  13. package/lib/commonjs/events/SleepTimerTriggered.js +2 -0
  14. package/lib/commonjs/events/SleepTimerTriggered.js.map +1 -0
  15. package/lib/commonjs/events/index.js +13 -0
  16. package/lib/commonjs/events/index.js.map +1 -1
  17. package/lib/module/NativeTrackPlayer.js.map +1 -1
  18. package/lib/module/audio.js +54 -5
  19. package/lib/module/audio.js.map +1 -1
  20. package/lib/module/events/SleepTimerTriggered.js +2 -0
  21. package/lib/module/events/SleepTimerTriggered.js.map +1 -0
  22. package/lib/module/events/index.js +2 -0
  23. package/lib/module/events/index.js.map +1 -1
  24. package/lib/typescript/src/NativeTrackPlayer.d.ts +4 -0
  25. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  26. package/lib/typescript/src/audio.d.ts +46 -4
  27. package/lib/typescript/src/audio.d.ts.map +1 -1
  28. package/lib/typescript/src/events/SleepTimerTriggered.d.ts +5 -0
  29. package/lib/typescript/src/events/SleepTimerTriggered.d.ts.map +1 -0
  30. package/lib/typescript/src/events/index.d.ts +5 -1
  31. package/lib/typescript/src/events/index.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/NativeTrackPlayer.ts +6 -0
  34. package/src/audio.ts +67 -6
  35. package/src/events/SleepTimerTriggered.ts +4 -0
  36. package/src/events/index.ts +4 -0
@@ -16,6 +16,7 @@ Pod::Spec.new do |s|
16
16
  s.source_files = "ios/**/*.{h,m,mm,swift}"
17
17
  s.exclude_files = "ios/tests/**", "ios/Package.swift"
18
18
  s.private_header_files = "ios/**/*.h"
19
+ s.frameworks = "CarPlay"
19
20
 
20
21
  install_modules_dependencies(s)
21
22
  end
@@ -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
+ }