react-native-nitro-player 1.4.4-alpha.0 → 1.5.0-alpha.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.
Files changed (67) hide show
  1. package/NitroPlayer.podspec +6 -0
  2. package/README.md +113 -0
  3. package/android/build.gradle +8 -0
  4. package/android/src/main/AndroidManifest.xml +11 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridCast.kt +79 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +10 -7
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +122 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +13 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +7 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +160 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastConfig.kt +23 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastOptionsProvider.kt +30 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroMediaItemConverter.kt +53 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +33 -0
  15. package/ios/HybridCast.swift +63 -0
  16. package/ios/core/CastSessionManager.swift +367 -0
  17. package/ios/core/TrackPlayerCast.swift +109 -0
  18. package/ios/core/TrackPlayerCore.swift +23 -2
  19. package/ios/core/TrackPlayerPlayback.swift +32 -0
  20. package/ios/core/TrackPlayerQueue.swift +8 -1
  21. package/ios/core/TrackPlayerQueueBuild.swift +21 -0
  22. package/lib/components/CastButton.d.ts +44 -0
  23. package/lib/components/CastButton.js +100 -0
  24. package/lib/hooks/castCallbackManager.d.ts +19 -0
  25. package/lib/hooks/castCallbackManager.js +43 -0
  26. package/lib/hooks/index.d.ts +3 -0
  27. package/lib/hooks/index.js +3 -0
  28. package/lib/hooks/useCastState.d.ts +27 -0
  29. package/lib/hooks/useCastState.js +49 -0
  30. package/lib/hooks/useIsCasting.d.ts +17 -0
  31. package/lib/hooks/useIsCasting.js +43 -0
  32. package/lib/index.d.ts +5 -0
  33. package/lib/index.js +4 -1
  34. package/lib/specs/Cast.nitro.d.ts +58 -0
  35. package/lib/specs/Cast.nitro.js +1 -0
  36. package/nitro.json +10 -0
  37. package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +3 -1
  38. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +18 -0
  39. package/nitrogen/generated/android/c++/JCastState.hpp +64 -0
  40. package/nitrogen/generated/android/c++/JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp +83 -0
  41. package/nitrogen/generated/android/c++/JHybridCastSpec.cpp +115 -0
  42. package/nitrogen/generated/android/c++/JHybridCastSpec.hpp +69 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/CastState.kt +25 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridCastSpec.kt +85 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +38 -13
  47. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +176 -125
  48. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +8 -0
  49. package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +8 -0
  50. package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +12 -0
  51. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.cpp +11 -0
  52. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.hpp +133 -0
  53. package/nitrogen/generated/ios/swift/CastState.swift +48 -0
  54. package/nitrogen/generated/ios/swift/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.swift +65 -0
  55. package/nitrogen/generated/ios/swift/HybridCastSpec.swift +61 -0
  56. package/nitrogen/generated/ios/swift/HybridCastSpec_cxx.swift +254 -0
  57. package/nitrogen/generated/shared/c++/CastState.hpp +84 -0
  58. package/nitrogen/generated/shared/c++/HybridCastSpec.cpp +27 -0
  59. package/nitrogen/generated/shared/c++/HybridCastSpec.hpp +75 -0
  60. package/package.json +3 -3
  61. package/src/components/CastButton.tsx +196 -0
  62. package/src/hooks/castCallbackManager.ts +55 -0
  63. package/src/hooks/index.ts +5 -0
  64. package/src/hooks/useCastState.ts +67 -0
  65. package/src/hooks/useIsCasting.ts +47 -0
  66. package/src/index.ts +9 -0
  67. package/src/specs/Cast.nitro.ts +72 -0
@@ -0,0 +1,30 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import android.content.Context
4
+ import com.google.android.gms.cast.framework.CastOptions
5
+ import com.google.android.gms.cast.framework.OptionsProvider
6
+ import com.google.android.gms.cast.framework.SessionProvider
7
+
8
+ /**
9
+ * Supplies [CastOptions] used to initialize the Cast `CastContext` singleton.
10
+ *
11
+ * Registered via the `com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME`
12
+ * manifest meta-data (declared in this library's AndroidManifest, merged into the
13
+ * host app). Apps that ship their own OptionsProvider (e.g. when also using
14
+ * react-native-google-cast) must override the meta-data with `tools:replace`.
15
+ *
16
+ * The receiver ID is read from [NitroCastConfig], so `Cast.configure(id)` can
17
+ * change it before Cast is first used.
18
+ */
19
+ class NitroCastOptionsProvider : OptionsProvider {
20
+ override fun getCastOptions(context: Context): CastOptions =
21
+ CastOptions.Builder()
22
+ .setReceiverApplicationId(NitroCastConfig.effectiveReceiverId)
23
+ // Keep the local session alive when the app is backgrounded so
24
+ // playback control survives notification/lock-screen interaction.
25
+ .setResumeSavedSession(true)
26
+ .setEnableReconnectionService(true)
27
+ .build()
28
+
29
+ override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
30
+ }
@@ -0,0 +1,53 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import androidx.media3.cast.DefaultMediaItemConverter
4
+ import androidx.media3.cast.MediaItemConverter
5
+ import androidx.media3.common.MediaItem
6
+ import androidx.media3.common.util.UnstableApi
7
+ import com.google.android.gms.cast.MediaQueueItem
8
+
9
+ /**
10
+ * [MediaItemConverter] for [androidx.media3.cast.CastPlayer].
11
+ *
12
+ * The library builds [MediaItem]s with a URI + metadata but **no MIME type**
13
+ * (local ExoPlayer infers it from the stream, and forcing one would risk picking
14
+ * the wrong extractor). The Cast SDK's [DefaultMediaItemConverter] however throws
15
+ * when the MIME type is missing, so this converter infers a sensible content type
16
+ * from the URL before delegating. Local playback is untouched — this only runs
17
+ * for items sent to a Cast device.
18
+ */
19
+ @UnstableApi
20
+ class NitroMediaItemConverter : MediaItemConverter {
21
+ private val delegate = DefaultMediaItemConverter()
22
+
23
+ override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem =
24
+ delegate.toMediaItem(mediaQueueItem)
25
+
26
+ override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem =
27
+ delegate.toMediaQueueItem(ensureMimeType(mediaItem))
28
+
29
+ private fun ensureMimeType(mediaItem: MediaItem): MediaItem {
30
+ val config = mediaItem.localConfiguration ?: return mediaItem
31
+ if (config.mimeType != null) return mediaItem
32
+ return mediaItem
33
+ .buildUpon()
34
+ .setMimeType(inferMimeType(config.uri.toString()))
35
+ .build()
36
+ }
37
+
38
+ /** Best-effort content type the Cast receiver can load; defaults to MP3 audio. */
39
+ private fun inferMimeType(url: String): String {
40
+ val path = url.substringBefore('?').substringBefore('#').lowercase()
41
+ return when {
42
+ path.endsWith(".m3u8") -> "application/x-mpegurl"
43
+ path.endsWith(".mpd") -> "application/dash+xml"
44
+ path.endsWith(".flac") -> "audio/flac"
45
+ path.endsWith(".wav") -> "audio/wav"
46
+ path.endsWith(".opus") -> "audio/opus"
47
+ path.endsWith(".ogg") -> "audio/ogg"
48
+ path.endsWith(".aac") -> "audio/aac"
49
+ path.endsWith(".m4a") || path.endsWith(".mp4") -> "audio/mp4"
50
+ else -> "audio/mpeg"
51
+ }
52
+ }
53
+ }
@@ -5,11 +5,13 @@ import android.os.Binder
5
5
  import android.os.Handler
6
6
  import android.os.IBinder
7
7
  import android.os.Looper
8
+ import androidx.media3.cast.CastPlayer
8
9
  import androidx.media3.common.util.UnstableApi
9
10
  import androidx.media3.exoplayer.ExoPlayer
10
11
  import androidx.media3.session.DefaultMediaNotificationProvider
11
12
  import androidx.media3.session.MediaSession
12
13
  import androidx.media3.session.MediaSessionService
14
+ import com.google.android.gms.cast.framework.CastContext
13
15
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
14
16
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
15
17
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
@@ -50,6 +52,9 @@ class NitroPlayerPlaybackService : MediaSessionService() {
50
52
  private var notificationProvider: DefaultMediaNotificationProvider? = null
51
53
  private val mainHandler = Handler(Looper.getMainLooper())
52
54
 
55
+ /** Owns the CastPlayer + Cast session lifecycle; null when Cast is unavailable. */
56
+ private var castSessionController: CastSessionController? = null
57
+
53
58
  @Volatile
54
59
  var trackPlayerCore: TrackPlayerCore? = null
55
60
 
@@ -59,6 +64,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
59
64
  val exoPlayer: ExoPlayer get() = player
60
65
  val session: MediaSession get() = mediaSession!!
61
66
  val handler: Handler get() = mainHandler
67
+ val castController: CastSessionController? get() = castSessionController
62
68
  }
63
69
 
64
70
  private val localBinder = lazy { LocalBinder() }
@@ -86,6 +92,31 @@ class NitroPlayerPlaybackService : MediaSessionService() {
86
92
  setMediaNotificationProvider(provider)
87
93
 
88
94
  addSession(mediaSession!!)
95
+
96
+ initCast()
97
+ }
98
+
99
+ /**
100
+ * Initialize Google Cast. Best-effort: when Google Play services or the Cast
101
+ * framework are unavailable (older devices, no Play services), the controller
102
+ * stays null and all Cast features become inert no-ops.
103
+ */
104
+ private fun initCast() {
105
+ try {
106
+ val castContext = CastContext.getSharedInstance(this)
107
+ val castPlayer = CastPlayer(castContext, NitroMediaItemConverter())
108
+ castSessionController =
109
+ CastSessionController(
110
+ castContext = castContext,
111
+ castPlayer = castPlayer,
112
+ localPlayer = player,
113
+ mediaSession = mediaSession!!,
114
+ mainHandler = mainHandler,
115
+ )
116
+ } catch (e: Exception) {
117
+ NitroPlayerLogger.log("PlaybackService") { "Cast unavailable: ${e.message}" }
118
+ castSessionController = null
119
+ }
89
120
  }
90
121
 
91
122
  private fun applyNotificationSmallIcon() {
@@ -133,6 +164,8 @@ class NitroPlayerPlaybackService : MediaSessionService() {
133
164
  NitroPlayerLogger.log("PlaybackService") { "onDestroy" }
134
165
  if (instance === this) instance = null
135
166
  notificationProvider = null
167
+ castSessionController?.release()
168
+ castSessionController = null
136
169
  mediaSession?.release()
137
170
  mediaSession = null
138
171
  player.release()
@@ -0,0 +1,63 @@
1
+ //
2
+ // HybridCast.swift
3
+ // NitroPlayer
4
+ //
5
+ // Nitro module exposing the Google Cast control surface to JS. Actual playback
6
+ // routing to/from the Cast device lives in TrackPlayerCore (see TrackPlayerCast).
7
+ //
8
+
9
+ import Foundation
10
+ import NitroModules
11
+
12
+ final class HybridCast: HybridCastSpec {
13
+ private let core: TrackPlayerCore
14
+ private var listenerIds: [Int64] = []
15
+
16
+ override init() {
17
+ core = TrackPlayerCore.shared
18
+ super.init()
19
+ }
20
+
21
+ func configure(receiverApplicationId: String?) throws -> Promise<Void> {
22
+ Promise.async { self.core.castConfigure(receiverApplicationId: receiverApplicationId) }
23
+ }
24
+
25
+ func isCasting() throws -> Bool {
26
+ core.isCasting
27
+ }
28
+
29
+ func getCastState() throws -> CastState {
30
+ core.castGetState()
31
+ }
32
+
33
+ func getCastDeviceName() throws -> Variant_NullType_String {
34
+ if let name = core.castGetDeviceName() {
35
+ return .second(name)
36
+ }
37
+ return .first(NullType.null)
38
+ }
39
+
40
+ func showCastPicker() throws {
41
+ core.castShowPicker()
42
+ }
43
+
44
+ func endCastSession() throws -> Promise<Void> {
45
+ Promise.async { self.core.castEndSession() }
46
+ }
47
+
48
+ func onCastStateChange(callback: @escaping (CastState, Variant_NullType_String?) -> Void) throws {
49
+ // Replace any prior listener so JS reloads don't accumulate handlers.
50
+ for id in listenerIds { core.removeOnCastStateChangeListener(id: id) }
51
+ listenerIds.removeAll()
52
+
53
+ let id = core.addOnCastStateChangeListener { state, deviceName in
54
+ callback(state, deviceName.map { .second($0) })
55
+ }
56
+ listenerIds.append(id)
57
+ }
58
+
59
+ deinit {
60
+ for id in listenerIds { core.removeOnCastStateChangeListener(id: id) }
61
+ listenerIds.removeAll()
62
+ }
63
+ }
@@ -0,0 +1,367 @@
1
+ //
2
+ // CastSessionManager.swift
3
+ // NitroPlayer
4
+ //
5
+ // Owns the Google Cast session lifecycle and remote media client, and bridges
6
+ // Cast events back into TrackPlayerCore. All GoogleCast usage is guarded by
7
+ // `#if canImport(GoogleCast)` so the library still builds when the SDK is not
8
+ // linked — in that case every method is an inert no-op and casting is disabled.
9
+ //
10
+
11
+ import Foundation
12
+ import MediaPlayer
13
+
14
+ #if canImport(GoogleCast)
15
+ import GoogleCast
16
+ #endif
17
+
18
+ final class CastSessionManager: NSObject {
19
+ weak var core: TrackPlayerCore?
20
+
21
+ private var isConfigured = false
22
+ private var loadedTracks: [TrackItem] = []
23
+ private var lastCurrentTrackId: String?
24
+ // GoogleCast reads are main-thread-affine; Nitro sync getters run off-main, so
25
+ // serve them from a cache refreshed on the main thread by the Cast callbacks.
26
+ private var cachedState: CastState = .noDevicesAvailable
27
+ private var cachedDeviceName: String?
28
+ /// Last position reported by the receiver, used to resume locally on disconnect.
29
+ private(set) var lastKnownRemotePosition: Double = 0
30
+ private var progressTimer: Timer?
31
+
32
+ // MARK: - Configuration
33
+
34
+ func configure(receiverApplicationId: String?) {
35
+ #if canImport(GoogleCast)
36
+ DispatchQueue.main.async { [weak self] in
37
+ guard let self, !self.isConfigured else { return }
38
+ self.isConfigured = true
39
+
40
+ let appId = receiverApplicationId ?? kGCKDefaultMediaReceiverApplicationID
41
+ let criteria = GCKDiscoveryCriteria(applicationID: appId)
42
+ let options = GCKCastOptions(discoveryCriteria: criteria)
43
+ options.suspendSessionsWhenBackgrounded = false
44
+ GCKCastContext.setSharedInstanceWith(options)
45
+
46
+ let context = GCKCastContext.sharedInstance()
47
+ context.sessionManager.add(self)
48
+ NotificationCenter.default.addObserver(
49
+ self,
50
+ selector: #selector(self.castStateDidChange),
51
+ name: NSNotification.Name.gckCastStateDidChange,
52
+ object: nil
53
+ )
54
+ self.emitCastState()
55
+ }
56
+ #endif
57
+ }
58
+
59
+ // MARK: - State
60
+
61
+ var isCasting: Bool {
62
+ #if canImport(GoogleCast)
63
+ return isConfigured && GCKCastContext.sharedInstance().sessionManager.currentCastSession != nil
64
+ #else
65
+ return false
66
+ #endif
67
+ }
68
+
69
+ /// Cached — safe to call from the JS thread.
70
+ func currentCastState() -> CastState { cachedState }
71
+
72
+ /// Cached — safe to call from the JS thread.
73
+ func deviceName() -> String? { cachedDeviceName }
74
+
75
+ // MARK: - UI / session control
76
+
77
+ func showCastPicker() {
78
+ #if canImport(GoogleCast)
79
+ DispatchQueue.main.async {
80
+ guard self.isConfigured else { return }
81
+ GCKCastContext.sharedInstance().presentCastDialog()
82
+ }
83
+ #endif
84
+ }
85
+
86
+ func endSession() {
87
+ #if canImport(GoogleCast)
88
+ DispatchQueue.main.async {
89
+ guard self.isConfigured else { return }
90
+ GCKCastContext.sharedInstance().sessionManager.endSessionAndStopCasting(true)
91
+ }
92
+ #endif
93
+ }
94
+
95
+ // MARK: - Transport (all dispatched to main — GoogleCast is main-thread only)
96
+
97
+ func play() {
98
+ #if canImport(GoogleCast)
99
+ onMain { self.remoteClient?.play() }
100
+ #endif
101
+ }
102
+
103
+ func pause() {
104
+ #if canImport(GoogleCast)
105
+ onMain { self.remoteClient?.pause() }
106
+ #endif
107
+ }
108
+
109
+ func seek(to position: Double) {
110
+ #if canImport(GoogleCast)
111
+ onMain {
112
+ let options = GCKMediaSeekOptions()
113
+ options.interval = position
114
+ self.remoteClient?.seek(with: options)
115
+ }
116
+ #endif
117
+ }
118
+
119
+ func skipToNext() {
120
+ #if canImport(GoogleCast)
121
+ onMain { self.remoteClient?.queueNextItem() }
122
+ #endif
123
+ }
124
+
125
+ func skipToPrevious() {
126
+ #if canImport(GoogleCast)
127
+ onMain { self.remoteClient?.queuePreviousItem() }
128
+ #endif
129
+ }
130
+
131
+ func setVolume(_ volume0to1: Float) {
132
+ #if canImport(GoogleCast)
133
+ onMain { self.remoteClient?.setStreamVolume(volume0to1) }
134
+ #endif
135
+ }
136
+
137
+ func setPlaybackRate(_ rate: Float) {
138
+ #if canImport(GoogleCast)
139
+ onMain { self.remoteClient?.setPlaybackRate(rate) }
140
+ #endif
141
+ }
142
+
143
+ func jump(toQueueIndex index: Int) {
144
+ #if canImport(GoogleCast)
145
+ onMain {
146
+ guard let client = self.remoteClient,
147
+ let status = client.mediaStatus,
148
+ index >= 0, index < Int(status.queueItemCount) else { return }
149
+ if let item = status.queueItem(at: UInt(index)) {
150
+ client.queueJumpToItem(withID: item.itemID)
151
+ }
152
+ }
153
+ #endif
154
+ }
155
+
156
+ // MARK: - Queue loading
157
+
158
+ func loadQueue(tracks: [TrackItem], startIndex: Int, position: Double, autoplay: Bool) {
159
+ #if canImport(GoogleCast)
160
+ onMain {
161
+ guard let client = self.remoteClient, !tracks.isEmpty else { return }
162
+ self.loadedTracks = tracks
163
+ let items = tracks.compactMap { self.makeQueueItem(for: $0, autoplay: autoplay) }
164
+ guard !items.isEmpty else { return }
165
+
166
+ let options = GCKMediaQueueLoadOptions()
167
+ options.startIndex = UInt(max(0, min(startIndex, items.count - 1)))
168
+ options.playPosition = position.isFinite ? position : 0
169
+ options.repeatMode = .off
170
+ client.queueLoad(items, with: options)
171
+ }
172
+ #endif
173
+ }
174
+
175
+ // MARK: - Private helpers
176
+
177
+ private func onMain(_ block: @escaping () -> Void) {
178
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
179
+ }
180
+
181
+ #if canImport(GoogleCast)
182
+ private var remoteClient: GCKRemoteMediaClient? {
183
+ guard isConfigured else { return nil }
184
+ return GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient
185
+ }
186
+
187
+ private func makeQueueItem(for track: TrackItem, autoplay: Bool) -> GCKMediaQueueItem? {
188
+ let urlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
189
+ guard let contentURL = URL(string: urlString) else { return nil }
190
+
191
+ let metadata = GCKMediaMetadata(metadataType: .musicTrack)
192
+ metadata.setString(track.title, forKey: kGCKMetadataKeyTitle)
193
+ metadata.setString(track.artist, forKey: kGCKMetadataKeyArtist)
194
+ metadata.setString(track.album, forKey: kGCKMetadataKeyAlbumTitle)
195
+ if let artwork = track.artwork, case let .second(art) = artwork, let artURL = URL(string: art) {
196
+ metadata.addImage(GCKImage(url: artURL, width: 0, height: 0))
197
+ }
198
+
199
+ let infoBuilder = GCKMediaInformationBuilder(contentURL: contentURL)
200
+ infoBuilder.streamType = .buffered
201
+ infoBuilder.contentType = inferMimeType(urlString)
202
+ infoBuilder.metadata = metadata
203
+ infoBuilder.customData = ["trackId": track.id]
204
+
205
+ let itemBuilder = GCKMediaQueueItemBuilder()
206
+ itemBuilder.mediaInformation = infoBuilder.build()
207
+ itemBuilder.autoplay = autoplay
208
+ itemBuilder.preloadTime = 5
209
+ return itemBuilder.build()
210
+ }
211
+
212
+ private func inferMimeType(_ url: String) -> String {
213
+ let path = url.components(separatedBy: "?").first?.lowercased() ?? url.lowercased()
214
+ if path.hasSuffix(".m3u8") { return "application/x-mpegurl" }
215
+ if path.hasSuffix(".mpd") { return "application/dash+xml" }
216
+ if path.hasSuffix(".flac") { return "audio/flac" }
217
+ if path.hasSuffix(".wav") { return "audio/wav" }
218
+ if path.hasSuffix(".opus") { return "audio/opus" }
219
+ if path.hasSuffix(".ogg") { return "audio/ogg" }
220
+ if path.hasSuffix(".aac") { return "audio/aac" }
221
+ if path.hasSuffix(".m4a") || path.hasSuffix(".mp4") { return "audio/mp4" }
222
+ return "audio/mpeg"
223
+ }
224
+
225
+ private func startProgressTimer() {
226
+ onMain {
227
+ self.progressTimer?.invalidate()
228
+ self.progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
229
+ self?.tickProgress()
230
+ }
231
+ }
232
+ }
233
+
234
+ private func stopProgressTimer() {
235
+ onMain {
236
+ self.progressTimer?.invalidate()
237
+ self.progressTimer = nil
238
+ }
239
+ }
240
+
241
+ private func tickProgress() {
242
+ guard let client = remoteClient else { return }
243
+ let position = client.approximateStreamPosition()
244
+ let duration = client.mediaStatus?.mediaInformation?.streamDuration ?? 0
245
+ let safeDuration = (duration.isFinite && duration > 0) ? duration : 0
246
+ if position.isFinite { lastKnownRemotePosition = position }
247
+ core?.emitCastProgress(position.isFinite ? position : 0, safeDuration)
248
+ updateNowPlaying(position: position, duration: safeDuration)
249
+ }
250
+
251
+ /// Resolve the track currently playing on the receiver from its custom data.
252
+ private func currentRemoteTrack(_ status: GCKMediaStatus?) -> TrackItem? {
253
+ guard let status else { return nil }
254
+ let item = status.queueItem(withItemID: status.currentItemID)
255
+ let info = item?.mediaInformation ?? status.mediaInformation
256
+ if let customData = info?.customData as? [String: Any],
257
+ let trackId = customData["trackId"] as? String {
258
+ return loadedTracks.first { $0.id == trackId }
259
+ }
260
+ if let url = info?.contentURL?.absoluteString {
261
+ return loadedTracks.first { DownloadManagerCore.shared.getEffectiveUrl(track: $0) == url }
262
+ }
263
+ return nil
264
+ }
265
+
266
+ private func mapPlayerState(_ state: GCKMediaPlayerState) -> TrackPlayerState {
267
+ switch state {
268
+ case .playing: return .playing
269
+ case .paused: return .paused
270
+ case .buffering, .loading: return .buffering
271
+ case .idle: return .stopped
272
+ default: return .paused
273
+ }
274
+ }
275
+
276
+ private func updateNowPlaying(position: Double, duration: Double) {
277
+ guard let track = lastCurrentTrackId.flatMap({ id in loadedTracks.first { $0.id == id } }) else { return }
278
+ var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
279
+ info[MPMediaItemPropertyTitle] = track.title
280
+ info[MPMediaItemPropertyArtist] = track.artist
281
+ info[MPMediaItemPropertyAlbumTitle] = track.album
282
+ if duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration }
283
+ if position.isFinite { info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = position }
284
+ info[MPNowPlayingInfoPropertyPlaybackRate] = (remoteClient?.mediaStatus?.playerState == .playing) ? 1.0 : 0.0
285
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = info
286
+ }
287
+ #endif
288
+
289
+ @objc private func castStateDidChange() {
290
+ emitCastState()
291
+ }
292
+
293
+ /// Refreshes the cache from live Cast state and notifies listeners. Callers must be on the main thread.
294
+ private func emitCastState() {
295
+ refreshCacheOnMain()
296
+ core?.notifyCastStateChangeListeners(cachedState, cachedDeviceName)
297
+ }
298
+
299
+ /// Re-read live Cast state into the cache. MUST be called on the main thread.
300
+ private func refreshCacheOnMain() {
301
+ #if canImport(GoogleCast)
302
+ guard isConfigured else {
303
+ cachedState = .noDevicesAvailable
304
+ cachedDeviceName = nil
305
+ return
306
+ }
307
+ switch GCKCastContext.sharedInstance().castState {
308
+ case .noDevicesAvailable: cachedState = .noDevicesAvailable
309
+ case .notConnected: cachedState = .notConnected
310
+ case .connecting: cachedState = .connecting
311
+ case .connected: cachedState = .connected
312
+ @unknown default: cachedState = .notConnected
313
+ }
314
+ cachedDeviceName = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
315
+ #endif
316
+ }
317
+ }
318
+
319
+ #if canImport(GoogleCast)
320
+ // MARK: - GCKSessionManagerListener
321
+
322
+ extension CastSessionManager: GCKSessionManagerListener {
323
+ func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKSession) {
324
+ handleSessionStarted(session)
325
+ }
326
+
327
+ func sessionManager(_ sessionManager: GCKSessionManager, didResumeSession session: GCKSession) {
328
+ handleSessionStarted(session)
329
+ }
330
+
331
+ func sessionManager(_ sessionManager: GCKSessionManager, willEnd session: GCKSession) {
332
+ (session as? GCKCastSession)?.remoteMediaClient?.remove(self)
333
+ }
334
+
335
+ func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
336
+ stopProgressTimer()
337
+ lastCurrentTrackId = nil
338
+ core?.handleCastDisconnected()
339
+ emitCastState()
340
+ }
341
+
342
+ private func handleSessionStarted(_ session: GCKSession) {
343
+ (session as? GCKCastSession)?.remoteMediaClient?.add(self)
344
+ startProgressTimer()
345
+ core?.handleCastConnected()
346
+ emitCastState()
347
+ }
348
+ }
349
+
350
+ // MARK: - GCKRemoteMediaClientListener
351
+
352
+ extension CastSessionManager: GCKRemoteMediaClientListener {
353
+ func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
354
+ guard let core else { return }
355
+
356
+ // Track change
357
+ if let track = currentRemoteTrack(mediaStatus), track.id != lastCurrentTrackId {
358
+ lastCurrentTrackId = track.id
359
+ core.emitCastTrackChange(track)
360
+ }
361
+
362
+ // Playback state
363
+ let state = mapPlayerState(mediaStatus?.playerState ?? .unknown)
364
+ core.emitCastPlaybackState(state)
365
+ }
366
+ }
367
+ #endif
@@ -0,0 +1,109 @@
1
+ //
2
+ // TrackPlayerCast.swift
3
+ // NitroPlayer
4
+ //
5
+ // Google Cast backend integration for TrackPlayerCore.
6
+ //
7
+ // Casting is modelled as routing: while `isCasting` is true, the public
8
+ // playback methods forward to the CastSessionManager (remote device) instead of
9
+ // the local AVQueuePlayer, the local player is paused, and the queue is mirrored
10
+ // to the receiver. The same queue model (currentTracks / temp queues) stays the
11
+ // single source of truth, so disconnect can restore local playback seamlessly.
12
+ //
13
+
14
+ import AVFoundation
15
+ import Foundation
16
+
17
+ extension TrackPlayerCore {
18
+
19
+ // MARK: - Session lifecycle (called by CastSessionManager)
20
+
21
+ /// A Cast session became active — transfer current playback to the device.
22
+ func handleCastConnected() {
23
+ playerQueue.async { [weak self] in
24
+ guard let self else { return }
25
+ let wasPlaying = (self.player?.rate ?? 0) > 0 || self.intendedToPlay
26
+ let rawPosition = self.player?.currentTime().seconds ?? 0
27
+ let position = rawPosition.isFinite ? rawPosition : 0
28
+
29
+ self.isCasting = true
30
+ self.player?.pause() // silence local output — audio now comes from the device
31
+
32
+ self.loadCastQueue(autoplay: wasPlaying, position: position)
33
+ }
34
+ }
35
+
36
+ /// The Cast session ended — rebuild local playback at the last remote position.
37
+ func handleCastDisconnected() {
38
+ playerQueue.async { [weak self] in
39
+ guard let self else { return }
40
+ guard self.isCasting else { return }
41
+ self.isCasting = false
42
+
43
+ let resumePosition = self.castManager?.lastKnownRemotePosition ?? 0
44
+ let index = self.currentTrackIndex
45
+ guard index >= 0 && index < self.currentTracks.count else { return }
46
+
47
+ _ = self.rebuildQueueFromPlaylistIndex(index: index)
48
+ if resumePosition.isFinite && resumePosition > 0 {
49
+ let time = CMTime(seconds: resumePosition, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
50
+ self.player?.seek(to: time)
51
+ }
52
+ if self.intendedToPlay {
53
+ self.player?.rate = Float(self.currentPlaybackSpeed)
54
+ }
55
+ }
56
+ }
57
+
58
+ /// Mirror the current effective queue to the Cast device, starting at the current track.
59
+ /// Must be called on `playerQueue`.
60
+ func loadCastQueue(autoplay: Bool, position: Double) {
61
+ let queue = getActualQueueInternal()
62
+ guard !queue.isEmpty else { return }
63
+ let current = getCurrentTrack()
64
+ let startIndex = current
65
+ .flatMap { c in queue.firstIndex(where: { $0.id == c.id }) }
66
+ ?? max(0, currentTrackIndex)
67
+ castManager?.loadQueue(tracks: queue, startIndex: startIndex, position: position, autoplay: autoplay)
68
+ }
69
+
70
+ // MARK: - Emit (cast-derived events → JS listeners)
71
+ // These bypass getStateInternal()/notify* which read the (paused) local player.
72
+
73
+ func emitCastTrackChange(_ track: TrackItem) {
74
+ if let idx = currentTracks.firstIndex(where: { $0.id == track.id }) {
75
+ currentTrackIndex = idx
76
+ currentTemporaryType = .none
77
+ }
78
+ onChangeTrackListeners.forEach { $0(track, .skip) }
79
+ }
80
+
81
+ func emitCastPlaybackState(_ state: TrackPlayerState) {
82
+ onPlaybackStateChangeListeners.forEach { $0(state, nil) }
83
+ }
84
+
85
+ func emitCastProgress(_ position: Double, _ duration: Double) {
86
+ onProgressListeners.forEach { $0(position, duration, nil) }
87
+ }
88
+
89
+ func notifyCastStateChangeListeners(_ state: CastState, _ deviceName: String?) {
90
+ onCastStateChangeListeners.forEach { $0(state, deviceName) }
91
+ }
92
+
93
+ // MARK: - Accessors (used by HybridCast)
94
+
95
+ func castConfigure(receiverApplicationId: String?) {
96
+ castManager?.configure(receiverApplicationId: receiverApplicationId)
97
+ }
98
+
99
+ func castGetState() -> CastState {
100
+ if isCasting { return .connected }
101
+ return castManager?.currentCastState() ?? .noDevicesAvailable
102
+ }
103
+
104
+ func castGetDeviceName() -> String? { castManager?.deviceName() }
105
+
106
+ func castShowPicker() { castManager?.showCastPicker() }
107
+
108
+ func castEndSession() { castManager?.endSession() }
109
+ }