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.
- package/NitroPlayer.podspec +6 -0
- package/README.md +113 -0
- package/android/build.gradle +8 -0
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridCast.kt +79 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +10 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +122 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +7 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +160 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastConfig.kt +23 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastOptionsProvider.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroMediaItemConverter.kt +53 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +33 -0
- package/ios/HybridCast.swift +63 -0
- package/ios/core/CastSessionManager.swift +367 -0
- package/ios/core/TrackPlayerCast.swift +109 -0
- package/ios/core/TrackPlayerCore.swift +23 -2
- package/ios/core/TrackPlayerPlayback.swift +32 -0
- package/ios/core/TrackPlayerQueue.swift +8 -1
- package/ios/core/TrackPlayerQueueBuild.swift +21 -0
- package/lib/components/CastButton.d.ts +44 -0
- package/lib/components/CastButton.js +100 -0
- package/lib/hooks/castCallbackManager.d.ts +19 -0
- package/lib/hooks/castCallbackManager.js +43 -0
- package/lib/hooks/index.d.ts +3 -0
- package/lib/hooks/index.js +3 -0
- package/lib/hooks/useCastState.d.ts +27 -0
- package/lib/hooks/useCastState.js +49 -0
- package/lib/hooks/useIsCasting.d.ts +17 -0
- package/lib/hooks/useIsCasting.js +43 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +4 -1
- package/lib/specs/Cast.nitro.d.ts +58 -0
- package/lib/specs/Cast.nitro.js +1 -0
- package/nitro.json +10 -0
- package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +3 -1
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +18 -0
- package/nitrogen/generated/android/c++/JCastState.hpp +64 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp +83 -0
- package/nitrogen/generated/android/c++/JHybridCastSpec.cpp +115 -0
- package/nitrogen/generated/android/c++/JHybridCastSpec.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/CastState.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridCastSpec.kt +85 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +38 -13
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +176 -125
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +8 -0
- package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +8 -0
- package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +12 -0
- package/nitrogen/generated/ios/c++/HybridCastSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridCastSpecSwift.hpp +133 -0
- package/nitrogen/generated/ios/swift/CastState.swift +48 -0
- package/nitrogen/generated/ios/swift/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.swift +65 -0
- package/nitrogen/generated/ios/swift/HybridCastSpec.swift +61 -0
- package/nitrogen/generated/ios/swift/HybridCastSpec_cxx.swift +254 -0
- package/nitrogen/generated/shared/c++/CastState.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridCastSpec.cpp +27 -0
- package/nitrogen/generated/shared/c++/HybridCastSpec.hpp +75 -0
- package/package.json +3 -3
- package/src/components/CastButton.tsx +196 -0
- package/src/hooks/castCallbackManager.ts +55 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useCastState.ts +67 -0
- package/src/hooks/useIsCasting.ts +47 -0
- package/src/index.ts +9 -0
- 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
|
+
}
|