diff --git a/README.md b/README.md index a849cca..02a3a0a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ videojs v10 on the web (ios not implemented yet, PR welcome) tracks at runtime. - **Rich subtitle support**: vtt, srt, ass (via [jassub](https://github.com/ThaUnknown/jassub)) and pgs (via [libpgs](https://github.com/Arcus92/libpgs-js)) - **Picture-in-Picture**: enter PiP automatically or on demand on Android. +- **Chromecast**: cast to a receiver on both web and Android . - **and basic player stuff**: media sessions, playlists, hook based api... @@ -41,6 +42,7 @@ The plugin will: - Enable Picture-in-Picture on your `MainActivity` (declares `supportsPictureInPicture`, adds the required `configChanges`, and hooks the pip lifecycle callbacks). +- Register omni's Chromecast `OptionsProvider` If you are not using Expo, replicate those manifest/activity changes manually. diff --git a/android/build.gradle b/android/build.gradle index 41d854a..6ae880c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -149,6 +149,10 @@ dependencies { implementation("androidx.media3:media3-ui:1.4.1") implementation 'androidx.media3:media3-exoplayer:1.10.0' implementation("androidx.media3:media3-exoplayer-hls:1.10.0") + + implementation("androidx.media3:media3-cast:1.10.0") + implementation("com.google.android.gms:play-services-cast-framework:21.5.0") + implementation("androidx.mediarouter:mediarouter:1.7.0") } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/dev/zoriya/omni/EventMap.kt b/android/src/main/java/dev/zoriya/omni/EventMap.kt index 462bb87..ad0403a 100644 --- a/android/src/main/java/dev/zoriya/omni/EventMap.kt +++ b/android/src/main/java/dev/zoriya/omni/EventMap.kt @@ -18,6 +18,7 @@ import androidx.media3.common.Player.STATE_READY import androidx.media3.common.Tracks import androidx.media3.common.VideoSize import com.margelo.nitro.omni.BoolProperty +import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.HybridOmniEventMapSpec import com.margelo.nitro.omni.NumberProperty import com.margelo.nitro.omni.PlayerStatus @@ -25,7 +26,7 @@ import com.margelo.nitro.omni.Rendition import com.margelo.nitro.omni.Track @SuppressLint("UnsafeOptInUsageError") -class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Listener { +class EventMap() : HybridOmniEventMapSpec(), Player.Listener { private val onPrevListeners = mutableSetOf<() -> Unit>() private val onNextListeners = mutableSetOf<() -> Unit>() private val onEndListeners = mutableSetOf<() -> Unit>() @@ -38,13 +39,57 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li private val stateListeners = mutableMapOf Unit>>() private val stateBoolListeners = mutableMapOf Unit>>() private val playerStatusListeners = mutableSetOf<(PlayerStatus) -> Unit>() + private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>() private var lastMediaItemIndex = 0 private var lastRendition: Rendition? = null private var lastIsAutoQuality: Boolean? = null - init { - player.addListener(this) + // swapped on cast start/end + private var _player: Player? = null + var player: Player + get() = _player!! + set(value) { + if (_player === value) return + _player?.removeListener(this) + _player = value + value.addListener(this) + lastMediaItemIndex = value.currentMediaItemIndex + lastRendition = null + lastIsAutoQuality = null + + // immediatly send all the state + val status = when (value.playbackState) { + STATE_BUFFERING -> PlayerStatus.LOADING + STATE_READY -> PlayerStatus.READYTOPLAY + else -> PlayerStatus.IDLE + } + playerStatusListeners.forEach { it(status) } + stateBoolListeners[BoolProperty.ISPLAYING]?.forEach { it(value.isPlaying) } + stateBoolListeners[BoolProperty.MUTED]?.forEach { it(value.volume <= 0f) } + stateListeners[NumberProperty.VOLUME]?.forEach { it(value.volume.toDouble()) } + stateListeners[NumberProperty.PLAYBACKRATE]?.forEach { + it(value.playbackParameters.speed.toDouble()) + } + stateListeners[NumberProperty.CURRENTTIME]?.forEach { + it((value.currentPosition.toDouble() / 1000.0).coerceAtLeast(0.0)) + } + stateListeners[NumberProperty.BUFFERED]?.forEach { + it((value.totalBufferedDuration.toDouble() / 1000.0).coerceAtLeast(0.0)) + } + stateListeners[NumberProperty.DURATION]?.forEach { + val duration = value.duration + it( + if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast( + 0.0 + ) + ) + } + onTracksChanged(value.currentTracks) + } + + fun emitCastStatus(status: CastStatus) { + castStatusListeners.forEach { it(status) } } private fun selectedTrack(trackType: Int): Track? { @@ -83,6 +128,7 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li } } else null } + else -> (0 until group.length).firstOrNull { group.isTrackSelected(it) } } ?: return null @@ -121,6 +167,7 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li onEndListeners.forEach { it() } PlayerStatus.IDLE } + else -> PlayerStatus.IDLE } playerStatusListeners.forEach { it(state) } @@ -213,7 +260,11 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li } stateListeners[NumberProperty.DURATION]?.forEach { val duration = player.duration - it(if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast(0.0)) + it( + if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast( + 0.0 + ) + ) } } @@ -241,6 +292,14 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li playerStatusListeners.remove(cb) } + override fun addCastStatusListener(cb: (value: CastStatus) -> Unit) { + castStatusListeners.add(cb) + } + + override fun removeCastStatusListener(cb: (value: CastStatus) -> Unit) { + castStatusListeners.remove(cb) + } + override fun addOnEndListener(cb: () -> Unit) { onEndListeners.add(cb) } diff --git a/android/src/main/java/dev/zoriya/omni/OmniCastOptionsProvider.kt b/android/src/main/java/dev/zoriya/omni/OmniCastOptionsProvider.kt new file mode 100644 index 0000000..4750bbb --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/OmniCastOptionsProvider.kt @@ -0,0 +1,20 @@ +package dev.zoriya.omni + +import android.content.Context +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionProvider + +class OmniCastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions { + val appId = OmniPlayer.receiverApplicationId + ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID + + return CastOptions.Builder() + .setReceiverApplicationId(appId) + .build() + } + + override fun getAdditionalSessionProviders(context: Context): List? = null +} diff --git a/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt new file mode 100644 index 0000000..4339eae --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt @@ -0,0 +1,74 @@ +package dev.zoriya.omni + +import android.annotation.SuppressLint +import androidx.media3.cast.CastTrackSelector +import androidx.media3.common.C +import androidx.media3.common.TrackGroup +import androidx.media3.common.util.Util +import com.google.android.gms.cast.MediaTrack +import com.google.common.collect.ImmutableSet + +@SuppressLint("UnsafeOptInUsageError") +class OmniCastTrackSelector : CastTrackSelector() { + override fun evaluate(request: CastTrackSelectorRequest): CastTrackSelectorResult { + val params = request.trackSelectionParameters + val selections = LinkedHashSet(request.currentlySelectedTrackGroups) + + selectType( + selections, + request, + castType = MediaTrack.TYPE_AUDIO, + languages = params.preferredAudioLanguages, + labels = params.preferredAudioLabels, + disabled = C.TRACK_TYPE_AUDIO in params.disabledTrackTypes, + selectDefault = true, + ) + selectType( + selections, + request, + castType = MediaTrack.TYPE_TEXT, + languages = params.preferredTextLanguages, + labels = params.preferredTextLabels, + disabled = C.TRACK_TYPE_TEXT in params.disabledTrackTypes, + selectDefault = false, + ) + + return request.buildResultUpon() + .setSelections(ImmutableSet.copyOf(selections)) + .build() + } + + private fun selectType( + selections: MutableSet, + request: CastTrackSelectorRequest, + castType: Int, + languages: List, + labels: List, + disabled: Boolean, + selectDefault: Boolean, + ) { + val tracks = request.mediaTracks + val groups = request.trackGroupList + val indices = tracks.indices.filter { tracks[it].type == castType } + if (indices.isEmpty()) return + + val previous = indices.filter { groups[it] in selections } + indices.forEach { selections.remove(groups[it]) } + if (disabled) return + + val match = indices.firstOrNull { i -> + val name: String? = tracks[i].name + val language: String? = tracks[i].language + (labels.isNotEmpty() && name != null && name in labels) || + (languages.isNotEmpty() && language != null && + Util.normalizeLanguageCode(language) in languages) + } + val chosen = when { + match != null -> match + previous.isNotEmpty() -> previous.first() + selectDefault -> indices.first() + else -> null + } + chosen?.let { selections.add(groups[it]) } + } +} diff --git a/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt b/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt new file mode 100644 index 0000000..9e58ea8 --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt @@ -0,0 +1,95 @@ +package dev.zoriya.omni + +import android.annotation.SuppressLint +import androidx.core.net.toUri +import androidx.media3.cast.MediaItemConverter +import androidx.media3.common.MediaItem +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaQueueItem +import com.google.android.gms.cast.MediaTrack +import com.google.android.gms.common.images.WebImage +import org.json.JSONObject +import com.google.android.gms.cast.MediaMetadata as CastMediaMetadata + +@SuppressLint("UnsafeOptInUsageError") +class OmniMediaItemConverter : MediaItemConverter { + override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem { + val info = mediaQueueItem.media + val uri = info?.contentUrl ?: info?.contentId ?: "" + val builder = MediaItem.Builder() + .setUri(uri) + .setMediaId(info?.contentId ?: uri) + info?.contentType?.let { builder.setMimeType(it) } + info?.metadata?.let { md -> + builder.setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(md.getString(CastMediaMetadata.KEY_TITLE)) + .setArtist(md.getString(CastMediaMetadata.KEY_ARTIST)) + .setAlbumTitle(md.getString(CastMediaMetadata.KEY_ALBUM_TITLE)) + .build() + ) + } + return builder.build() + } + + override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + val local = requireNotNull(mediaItem.localConfiguration) { + "MediaItem must have a localConfiguration to be cast" + } + val uri = local.uri.toString() + val extras = mediaItem.requestMetadata.extras + val contentId = extras?.getString(OmniPlayer.CAST_ID_EXTRA) ?: uri + + val meta = mediaItem.mediaMetadata + val castMetadata = CastMediaMetadata(CastMediaMetadata.MEDIA_TYPE_MOVIE).apply { + meta.title?.let { putString(CastMediaMetadata.KEY_TITLE, it.toString()) } + meta.artist?.let { putString(CastMediaMetadata.KEY_ARTIST, it.toString()) } + meta.albumTitle?.let { putString(CastMediaMetadata.KEY_ALBUM_TITLE, it.toString()) } + meta.artworkUri?.let { addImage(WebImage(it)) } + } + + val customData = extras?.getString(OmniPlayer.CAST_DATA_EXTRA)?.let { + try { + JSONObject(it) + } catch (_: Throwable) { + null + } + } + + val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle -> + MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT) + .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) + .setContentId(subtitle.uri.toString()) + .setContentType(subtitle.mimeType ?: "text/vtt") + .apply { + subtitle.label?.let { setName(it) } + subtitle.language?.let { setLanguage(it) } + } + .build() + } + + val path = uri.toUri().path?.lowercase() ?: uri.lowercase() + val contentType = local.mimeType ?: when { + path.endsWith(".mpd") -> "application/dash+xml" + path.endsWith(".mp4") -> "video/mp4" + path.endsWith(".webm") -> "video/webm" + path.endsWith(".mkv") -> "video/x-matroska" + else -> "application/x-mpegurl" + } + + val mediaInfo = MediaInfo.Builder(contentId) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentUrl(uri) + .setContentType(contentType) + .setMetadata(castMetadata) + .apply { + if (tracks.isNotEmpty()) setMediaTracks(tracks) + customData?.let { setCustomData(it) } + } + .build() + + return MediaQueueItem.Builder(mediaInfo) + .setAutoplay(true) + .build() + } +} diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 6ecdbca..cf870bf 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -32,21 +32,32 @@ import androidx.core.net.toUri import androidx.media3.common.MediaItem.RequestMetadata import androidx.media3.common.MediaItem.SubtitleConfiguration import androidx.media3.common.TrackSelectionOverride +import androidx.media3.cast.CastPlayer +import androidx.media3.cast.RemoteCastPlayer import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import androidx.mediarouter.app.MediaRouteChooserDialog +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastState +import com.google.android.gms.cast.framework.CastStateListener +import com.margelo.nitro.omni.CastOptions import dev.zoriya.omni.utils.ThreadHelper.mainThreadProperty import dev.zoriya.omni.utils.ThreadHelper.runOnMainThread import dev.zoriya.omni.utils.ThreadHelper.runOnMainThreadSync +import org.json.JSONObject @SuppressLint("UnsafeOptInUsageError") -class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : HybridOmniPlayerSpec() { +class OmniPlayer( + private val backend: AndroidBackend = AndroidBackend.VLC, + castOptions: CastOptions? = null, +) : HybridOmniPlayerSpec() { private val ctx = NitroModules.applicationContext ?: throw Error("No Context available!") // exoplayer only, used to specify request headers. private var httpDataSourceFactory: DefaultHttpDataSource.Factory? = null - val player: Player = runOnMainThreadSync { + val localPlayer: Player = runOnMainThreadSync { when (backend) { AndroidBackend.EXOPLAYER -> { val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) @@ -61,7 +72,37 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb AndroidBackend.VLC -> VlcPlayer(ctx) } } - override val eventMap = EventMap(player) + override val eventMap = EventMap() + + private var castContext: CastContext? = null + private val castStateListener = + CastStateListener { eventMap.emitCastStatus(computeCastStatus()) } + + val player: Player = runOnMainThreadSync { + castOptions?.receiverApplicationId?.let { receiverApplicationId = it } + val cc = try { + CastContext.getSharedInstance(ctx) + } catch (_: Throwable) { + // No Google Play Services / Cast SDK -> casting unsupported. + null + } + castContext = cc + val active = if (cc == null) { + localPlayer + } else { + cc.addCastStateListener(castStateListener) + val remote = RemoteCastPlayer.Builder(ctx) + .setMediaItemConverter(OmniMediaItemConverter()) + .setTrackSelector(OmniCastTrackSelector()) + .build() + CastPlayer.Builder(ctx) + .setLocalPlayer(localPlayer) + .setRemotePlayer(remote) + .build() + } + eventMap.player = active + active + } override var showNotification: Boolean? = false set(value) { @@ -69,20 +110,41 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb if (notificationPlayer != null && notificationPlayer?.isPlaying == true) { throw Error("Two players can't display notifications at the same time.") } - notificationPlayer = player + notificationPlayer = localPlayer ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java)) - } else if (field == true && notificationPlayer == player) { + } else if (field == true && notificationPlayer == localPlayer) { ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) notificationPlayer = null } field = value } - // TODO: cast is not implemented on the native (Android) side yet. - override val castStatus: CastStatus get() = CastStatus.UNSUPPORTED + override val castStatus: CastStatus + get() = runOnMainThreadSync { computeCastStatus() } + + private fun computeCastStatus(): CastStatus { + val cc = castContext ?: return CastStatus.UNSUPPORTED + return when (cc.castState) { + CastState.NO_DEVICES_AVAILABLE -> CastStatus.UNAVAILABLE + CastState.NOT_CONNECTED -> CastStatus.AVAILABLE + CastState.CONNECTING -> CastStatus.CONNECTING + CastState.CONNECTED -> CastStatus.CONNECTED + else -> CastStatus.UNAVAILABLE + } + } override fun toggleCastStatus() { - // TODO: implement casting on Android. + runOnMainThread { + val cc = castContext ?: return@runOnMainThread + val session = cc.sessionManager.currentCastSession + if (session != null && session.isConnected) { + cc.sessionManager.endCurrentSession(true) + return@runOnMainThread + } + val activity = ctx.currentActivity ?: return@runOnMainThread + val selector = cc.mergedSelector ?: return@runOnMainThread + MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show() + } } override fun dispose() { @@ -90,18 +152,20 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb super.dispose() eventMap.dispose() - runOnMainThread { player.release() } + runOnMainThread { + castContext?.removeCastStateListener(castStateListener) + // release both cast and local players. + player.release() + } } private fun buildMediaItem( src: com.margelo.nitro.omni.VideoSrc, metadata: com.margelo.nitro.omni.Metadata?, - subtitles: Array + subtitles: Array, + castId: String? = null, + castData: Map? = null, ): MediaItem { - // vlc only path (and it only supports a few headers :c) - val extras = if (src.headers.isEmpty()) null else Bundle(src.headers.size).apply { - for ((name, value) in src.headers) putString(name, value) - } return MediaItem.Builder() .setUri(src.uri) .setMimeType(src.mimeType) @@ -126,7 +190,16 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb .setRequestMetadata( RequestMetadata.Builder() .setMediaUri(src.uri.toUri()) - .apply { extras?.let { setExtras(it) } } + .setExtras( + Bundle().apply { + castId?.let { putString(CAST_ID_EXTRA, it) } + castData?.let { data -> + putString(CAST_DATA_EXTRA, JSONObject(data as Map<*, *>).toString()) + } + // vlc only path (and it only supports a few headers :c). + for ((name, value) in src.headers) putString(name, value) + } + ) .build() ) .build() @@ -135,9 +208,9 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb fun setSurface(holder: SurfaceHolder?) { runOnMainThread { if (holder == null) { - player.clearVideoSurface() + localPlayer.clearVideoSurface() } else { - player.setVideoSurfaceHolder(holder) + localPlayer.setVideoSurfaceHolder(holder) } } } @@ -211,7 +284,9 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() - runOnMainThread { player.setAudioAttributes(audioAttributes, handleAudioFocus) } + // audio focus only makes sense locally. not in a cast session + runOnMainThread { localPlayer.setAudioAttributes(audioAttributes, handleAudioFocus) } + val firstSrc = value.src.firstOrNull() if (firstSrc == null) { runOnMainThreadSync { @@ -220,25 +295,23 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb } return } - httpDataSourceFactory?.setDefaultRequestProperties(firstSrc.headers) - val currentItem = buildMediaItem(firstSrc, value.metadata, value.subtitles) + val currentItem = buildMediaItem( + firstSrc, + value.metadata, + value.subtitles, + value.castId, + value.castData, + ) val mediaItems = mutableListOf() - - if (value.metadata?.hasPrev == true) { - mediaItems.add(currentItem) - } - + if (value.metadata?.hasPrev == true) mediaItems.add(currentItem) mediaItems.add(currentItem) - - if (value.metadata?.hasNext == true) { - mediaItems.add(currentItem) - } + if (value.metadata?.hasNext == true) mediaItems.add(currentItem) runOnMainThreadSync { val startIndex = if (value.metadata?.hasPrev == true) 1 else 0 - val startPositionMs = (value.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0 + val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0 player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong()) player.prepare() } @@ -288,8 +361,23 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb } override fun selectSubtitle(subtitle: Track?) { + // Custom (ASS/PGS) subtitles can't be rendered by the cast receiver's + // native pipeline, so while casting they are forwarded over omni's + // message channel (the receiver draws them as an overlay). This mirrors + // the web behavior. + val custom = subtitle?.let { track -> + source?.subtitles?.firstOrNull { it.id == track.id }?.let { sub -> + val mime = sub.mimeType?.lowercase() ?: "" + val ext = sub.link.substringBefore('?').substringBefore('#') + .substringAfterLast('.', "").lowercase() + mime.contains("ass") || mime.contains("ssa") || mime.contains("pgs") || + ext == "ass" || ext == "ssa" || ext == "sup" + } + } ?: false runOnMainThreadSync { - if (subtitle == null) { + val session = castContext?.sessionManager?.currentCastSession + val casting = session?.isConnected == true + if (subtitle == null || (custom && casting)) { player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) @@ -302,6 +390,15 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb .setPreferredTextLabels(*(subtitle.label?.let { arrayOf(it) } ?: emptyArray())) .build() } + if (casting) { + val id = if (custom) subtitle?.id else null + val payload = JSONObject().apply { put("subtitle", id ?: JSONObject.NULL) } + try { + session?.sendMessage(CAST_MESSAGE_NAMESPACE, payload.toString()) + } catch (_: Throwable) { + // best effort; receiver may not support the namespace. + } + } } } @@ -383,6 +480,18 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb companion object { var notificationPlayer: Player? = null + + // Cast custom-message channel (shared with the web receiver) used to + // forward overlay (ASS/PGS) subtitle selection to the receiver. + const val CAST_MESSAGE_NAMESPACE = "urn:x-cast:dev.zoriya.omni" + + // MediaItem RequestMetadata extras keys carrying cast-only data. + const val CAST_ID_EXTRA = "dev.zoriya.omni.castId" + const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData" + + // Receiver application id passed at runtime via OmniProvider's `cast` + // prop; read by OmniCastOptionsProvider when the Cast SDK initializes. + var receiverApplicationId: String? = null } } diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt index 1a8b457..b5f9d62 100755 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt @@ -1,16 +1,20 @@ package dev.zoriya.omni -import android.util.Log import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.omni.AndroidBackend +import com.margelo.nitro.omni.CastOptions import com.margelo.nitro.omni.HybridOmniPlayerFactorySpec import com.margelo.nitro.omni.HybridOmniPlayerSpec import com.margelo.nitro.omni.PlayerBackend import com.margelo.nitro.omni.Source class OmniPlayerFactory(val context: ThemedReactContext) : HybridOmniPlayerFactorySpec() { - override fun createPlayer(props: Source?, backend: PlayerBackend?): HybridOmniPlayerSpec { - return OmniPlayer(backend?.android ?: AndroidBackend.VLC).apply { + override fun createPlayer( + props: Source?, + backend: PlayerBackend?, + cast: CastOptions?, + ): HybridOmniPlayerSpec { + return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast).apply { source = props } } diff --git a/android/src/main/java/dev/zoriya/omni/OmniView.kt b/android/src/main/java/dev/zoriya/omni/OmniView.kt index 5ec98d6..5b02279 100755 --- a/android/src/main/java/dev/zoriya/omni/OmniView.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniView.kt @@ -138,10 +138,10 @@ class OmniView(val context: ThemedReactContext) : return } - boundPlayer?.player?.removeListener(this) + boundPlayer?.localPlayer?.removeListener(this) boundPlayer?.setSurface(null) boundPlayer = omniPlayer - omniPlayer.player.addListener(this) + omniPlayer.localPlayer.addListener(this) if (surfaceReady) { omniPlayer.setSurface(surfaceView.holder) @@ -163,7 +163,7 @@ class OmniView(val context: ThemedReactContext) : if (!::player.isInitialized) return val omniPlayer = player as? OmniPlayer ?: return - boundPlayer?.player?.removeListener(this) + boundPlayer?.localPlayer?.removeListener(this) omniPlayer.setSurface(null) boundPlayer = null } diff --git a/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt b/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt index ff627ae..19aad4a 100644 --- a/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt @@ -118,6 +118,7 @@ class VlcPlayer(ctx: Context) : .add(COMMAND_SET_VOLUME) .add(COMMAND_SET_VIDEO_SURFACE) .add(COMMAND_SET_MEDIA_ITEM) + .add(COMMAND_CHANGE_MEDIA_ITEMS) .add(COMMAND_GET_CURRENT_MEDIA_ITEM) .add(COMMAND_GET_METADATA) .add(COMMAND_GET_TIMELINE) @@ -438,8 +439,10 @@ class VlcPlayer(ctx: Context) : override fun getPlaybackState(): Int = when { + playerError != null -> STATE_IDLE currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE player.media == null -> STATE_IDLE + player.playerState == IMedia.State.Opening -> STATE_BUFFERING player.isPlaying -> STATE_READY player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED else -> STATE_READY @@ -520,6 +523,7 @@ class VlcPlayer(ctx: Context) : Format.Builder() .setId(track.id) .setLabel(track.name) + .setLanguage(track.language) .setSampleMimeType("video/x-unknown") .build() } @@ -535,6 +539,7 @@ class VlcPlayer(ctx: Context) : val format = Format.Builder() .setId(track.id) .setLabel(track.name) + .setLanguage(track.language) .setSampleMimeType("audio/x-unknown") .build() val group = TrackGroup("vlc-audio-${track.id}", format) @@ -547,6 +552,7 @@ class VlcPlayer(ctx: Context) : val format = Format.Builder() .setId(track.id) .setLabel(track.name) + .setLanguage(track.language) .setSampleMimeType("text/x-unknown") .build() val group = TrackGroup("vlc-sub-${track.id}", format) diff --git a/example/App.tsx b/example/App.tsx index eca4ea8..14f4487 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -69,6 +69,7 @@ function PlayerExample({ const muted = usePlayerState("muted"); const volume = usePlayerState("volume"); const isAutoQuality = usePlayerState("isAutoQuality"); + const castStatus = usePlayerState("castStatus"); const [logs, setLogs] = useState([]); const [tracks, setTracks] = useState(() => ({ videos: [...player.videos], @@ -265,6 +266,21 @@ function PlayerExample({ + + player.toggleCastStatus()} + > + + {castStatus === "connected" || castStatus === "connecting" + ? "Stop cast" + : "Cast"} + + + Cast: {castStatus} + + Status: {status} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index ac177fd..a1f0936 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -25,5 +25,8 @@ + diff --git a/nitrogen/generated/android/OmniOnLoad.cpp b/nitrogen/generated/android/OmniOnLoad.cpp index 3c696cb..0e65fd1 100644 --- a/nitrogen/generated/android/OmniOnLoad.cpp +++ b/nitrogen/generated/android/OmniOnLoad.cpp @@ -19,6 +19,7 @@ #include "JFunc_void_double.hpp" #include "JFunc_void_bool.hpp" #include "JFunc_void_PlayerStatus.hpp" +#include "JFunc_void_CastStatus.hpp" #include "JFunc_void.hpp" #include "JFunc_void_std__string_std__string.hpp" #include "JFunc_void_std__string.hpp" @@ -65,6 +66,7 @@ void registerAllNatives() { margelo::nitro::omni::JFunc_void_double_cxx::registerNatives(); margelo::nitro::omni::JFunc_void_bool_cxx::registerNatives(); margelo::nitro::omni::JFunc_void_PlayerStatus_cxx::registerNatives(); + margelo::nitro::omni::JFunc_void_CastStatus_cxx::registerNatives(); margelo::nitro::omni::JFunc_void_cxx::registerNatives(); margelo::nitro::omni::JFunc_void_std__string_std__string_cxx::registerNatives(); margelo::nitro::omni::JFunc_void_std__string_cxx::registerNatives(); diff --git a/nitrogen/generated/android/c++/JCastOptions.hpp b/nitrogen/generated/android/c++/JCastOptions.hpp new file mode 100644 index 0000000..2f7aa0a --- /dev/null +++ b/nitrogen/generated/android/c++/JCastOptions.hpp @@ -0,0 +1,58 @@ +/// +/// JCastOptions.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "CastOptions.hpp" + +#include +#include + +namespace margelo::nitro::omni { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ struct "CastOptions" and the Kotlin data class "CastOptions". + */ + struct JCastOptions final: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/CastOptions;"; + + public: + /** + * Convert this Java/Kotlin-based struct to the C++ struct CastOptions by copying all values to C++. + */ + [[maybe_unused]] + [[nodiscard]] + CastOptions toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldReceiverApplicationId = clazz->getField("receiverApplicationId"); + jni::local_ref receiverApplicationId = this->getFieldValue(fieldReceiverApplicationId); + return CastOptions( + receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt + ); + } + + public: + /** + * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java. + */ + [[maybe_unused]] + static jni::local_ref fromCpp(const CastOptions& value) { + using JSignature = JCastOptions(jni::alias_ref); + static const auto clazz = javaClassStatic(); + static const auto create = clazz->getStaticMethod("fromCpp"); + return create( + clazz, + value.receiverApplicationId.has_value() ? jni::make_jstring(value.receiverApplicationId.value()) : nullptr + ); + } + }; + +} // namespace margelo::nitro::omni diff --git a/nitrogen/generated/android/c++/JFunc_void_CastStatus.hpp b/nitrogen/generated/android/c++/JFunc_void_CastStatus.hpp new file mode 100644 index 0000000..4e642b9 --- /dev/null +++ b/nitrogen/generated/android/c++/JFunc_void_CastStatus.hpp @@ -0,0 +1,77 @@ +/// +/// JFunc_void_CastStatus.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include + +#include "CastStatus.hpp" +#include +#include +#include "JCastStatus.hpp" + +namespace margelo::nitro::omni { + + using namespace facebook; + + /** + * Represents the Java/Kotlin callback `(value: CastStatus) -> Unit`. + * This can be passed around between C++ and Java/Kotlin. + */ + struct JFunc_void_CastStatus: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/Func_void_CastStatus;"; + + public: + /** + * Invokes the function this `JFunc_void_CastStatus` instance holds through JNI. + */ + void invoke(CastStatus value) const { + static const auto method = javaClassStatic()->getMethod /* value */)>("invoke"); + method(self(), JCastStatus::fromCpp(value)); + } + }; + + /** + * An implementation of Func_void_CastStatus that is backed by a C++ implementation (using `std::function<...>`) + */ + class JFunc_void_CastStatus_cxx final: public jni::HybridClass { + public: + static jni::local_ref fromCpp(const std::function& func) { + return JFunc_void_CastStatus_cxx::newObjectCxxArgs(func); + } + + public: + /** + * Invokes the C++ `std::function<...>` this `JFunc_void_CastStatus_cxx` instance holds. + */ + void invoke_cxx(jni::alias_ref value) { + _func(value->toCpp()); + } + + public: + [[nodiscard]] + inline const std::function& getFunction() const { + return _func; + } + + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/Func_void_CastStatus_cxx;"; + static void registerNatives() { + registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_CastStatus_cxx::invoke_cxx)}); + } + + private: + explicit JFunc_void_CastStatus_cxx(const std::function& func): _func(func) { } + + private: + friend HybridBase; + std::function _func; + }; + +} // namespace margelo::nitro::omni diff --git a/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.cpp b/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.cpp index d4fddb5..8ad91b0 100644 --- a/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.cpp +++ b/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.cpp @@ -13,6 +13,8 @@ namespace margelo::nitro::omni { enum class NumberProperty; } namespace margelo::nitro::omni { enum class BoolProperty; } // Forward declaration of `PlayerStatus` to properly resolve imports. namespace margelo::nitro::omni { enum class PlayerStatus; } +// Forward declaration of `CastStatus` to properly resolve imports. +namespace margelo::nitro::omni { enum class CastStatus; } // Forward declaration of `Track` to properly resolve imports. namespace margelo::nitro::omni { struct Track; } // Forward declaration of `Rendition` to properly resolve imports. @@ -29,6 +31,9 @@ namespace margelo::nitro::omni { struct Rendition; } #include "PlayerStatus.hpp" #include "JFunc_void_PlayerStatus.hpp" #include "JPlayerStatus.hpp" +#include "CastStatus.hpp" +#include "JFunc_void_CastStatus.hpp" +#include "JCastStatus.hpp" #include "JFunc_void.hpp" #include #include "JFunc_void_std__string_std__string.hpp" @@ -99,6 +104,14 @@ namespace margelo::nitro::omni { static const auto method = _javaPart->javaClassStatic()->getMethod /* cb */)>("removePlayerStatusListener_cxx"); method(_javaPart, JFunc_void_PlayerStatus_cxx::fromCpp(cb)); } + void JHybridOmniEventMapSpec::addCastStatusListener(const std::function& cb) { + static const auto method = _javaPart->javaClassStatic()->getMethod /* cb */)>("addCastStatusListener_cxx"); + method(_javaPart, JFunc_void_CastStatus_cxx::fromCpp(cb)); + } + void JHybridOmniEventMapSpec::removeCastStatusListener(const std::function& cb) { + static const auto method = _javaPart->javaClassStatic()->getMethod /* cb */)>("removeCastStatusListener_cxx"); + method(_javaPart, JFunc_void_CastStatus_cxx::fromCpp(cb)); + } void JHybridOmniEventMapSpec::addOnEndListener(const std::function& cb) { static const auto method = _javaPart->javaClassStatic()->getMethod /* cb */)>("addOnEndListener_cxx"); method(_javaPart, JFunc_void_cxx::fromCpp(cb)); diff --git a/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.hpp b/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.hpp index a76ee6b..bc6d924 100644 --- a/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.hpp +++ b/nitrogen/generated/android/c++/JHybridOmniEventMapSpec.hpp @@ -60,6 +60,8 @@ namespace margelo::nitro::omni { void removeStateBoolListener(BoolProperty key, const std::function& cb) override; void addPlayerStatusListener(const std::function& cb) override; void removePlayerStatusListener(const std::function& cb) override; + void addCastStatusListener(const std::function& cb) override; + void removeCastStatusListener(const std::function& cb) override; void addOnEndListener(const std::function& cb) override; void removeOnEndListener(const std::function& cb) override; void addOnPrevListener(const std::function& cb) override; diff --git a/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.cpp b/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.cpp index 1060128..16eac4c 100644 --- a/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.cpp +++ b/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.cpp @@ -23,6 +23,8 @@ namespace margelo::nitro::omni { enum class MixAudioMode; } namespace margelo::nitro::omni { struct PlayerBackend; } // Forward declaration of `AndroidBackend` to properly resolve imports. namespace margelo::nitro::omni { enum class AndroidBackend; } +// Forward declaration of `CastOptions` to properly resolve imports. +namespace margelo::nitro::omni { struct CastOptions; } #include #include "HybridOmniPlayerSpec.hpp" @@ -45,6 +47,8 @@ namespace margelo::nitro::omni { enum class AndroidBackend; } #include "JPlayerBackend.hpp" #include "AndroidBackend.hpp" #include "JAndroidBackend.hpp" +#include "CastOptions.hpp" +#include "JCastOptions.hpp" namespace margelo::nitro::omni { @@ -79,9 +83,9 @@ namespace margelo::nitro::omni { // Methods - std::shared_ptr JHybridOmniPlayerFactorySpec::createPlayer(const std::optional& props, const std::optional& backend) { - static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* props */, jni::alias_ref /* backend */)>("createPlayer"); - auto __result = method(_javaPart, props.has_value() ? JSource::fromCpp(props.value()) : nullptr, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr); + std::shared_ptr JHybridOmniPlayerFactorySpec::createPlayer(const std::optional& props, const std::optional& backend, const std::optional& cast) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* props */, jni::alias_ref /* backend */, jni::alias_ref /* cast */)>("createPlayer"); + auto __result = method(_javaPart, props.has_value() ? JSource::fromCpp(props.value()) : nullptr, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr, cast.has_value() ? JCastOptions::fromCpp(cast.value()) : nullptr); return __result->getJHybridOmniPlayerSpec(); } diff --git a/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.hpp b/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.hpp index 79ab9cc..151ef7a 100644 --- a/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.hpp +++ b/nitrogen/generated/android/c++/JHybridOmniPlayerFactorySpec.hpp @@ -54,7 +54,7 @@ namespace margelo::nitro::omni { public: // Methods - std::shared_ptr createPlayer(const std::optional& props, const std::optional& backend) override; + std::shared_ptr createPlayer(const std::optional& props, const std::optional& backend, const std::optional& cast) override; private: jni::global_ref _javaPart; diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/CastOptions.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/CastOptions.kt new file mode 100644 index 0000000..a8b165b --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/CastOptions.kt @@ -0,0 +1,51 @@ +/// +/// CastOptions.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.omni + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects + + +/** + * Represents the JavaScript object/struct "CastOptions". + */ +@DoNotStrip +@Keep +data class CastOptions( + @DoNotStrip + @Keep + val receiverApplicationId: String? +) { + /* primary constructor */ + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CastOptions) return false + return Objects.deepEquals(this.receiverApplicationId, other.receiverApplicationId) + } + + override fun hashCode(): Int { + return arrayOf( + receiverApplicationId + ).contentDeepHashCode() + } + + companion object { + /** + * Constructor called from C++ + */ + @DoNotStrip + @Keep + @Suppress("unused") + @JvmStatic + private fun fromCpp(receiverApplicationId: String?): CastOptions { + return CastOptions(receiverApplicationId) + } + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/Func_void_CastStatus.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/Func_void_CastStatus.kt new file mode 100644 index 0000000..d5eddca --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/Func_void_CastStatus.kt @@ -0,0 +1,80 @@ +/// +/// Func_void_CastStatus.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.omni + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import dalvik.annotation.optimization.FastNative + + +/** + * Represents the JavaScript callback `(value: enum) => void`. + * This can be either implemented in C++ (in which case it might be a callback coming from JS), + * or in Kotlin/Java (in which case it is a native callback). + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType") +fun interface Func_void_CastStatus: (CastStatus) -> Unit { + /** + * Call the given JS callback. + * @throws Throwable if the JS function itself throws an error, or if the JS function/runtime has already been deleted. + */ + @DoNotStrip + @Keep + override fun invoke(value: CastStatus): Unit +} + +/** + * Represents the JavaScript callback `(value: enum) => void`. + * This is implemented in C++, via a `std::function<...>`. + * The callback might be coming from JS. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "FunctionName", + "ConvertSecondaryConstructorToPrimary", "ClassName", "LocalVariableName", +) +class Func_void_CastStatus_cxx: Func_void_CastStatus { + @DoNotStrip + @Keep + private val mHybridData: HybridData + + @DoNotStrip + @Keep + private constructor(hybridData: HybridData) { + mHybridData = hybridData + } + + @DoNotStrip + @Keep + override fun invoke(value: CastStatus): Unit + = invoke_cxx(value) + + @FastNative + private external fun invoke_cxx(value: CastStatus): Unit +} + +/** + * Represents the JavaScript callback `(value: enum) => void`. + * This is implemented in Java/Kotlin, via a `(CastStatus) -> Unit`. + * The callback is always coming from native. + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType", "unused") +class Func_void_CastStatus_java(private val function: (CastStatus) -> Unit): Func_void_CastStatus { + @DoNotStrip + @Keep + override fun invoke(value: CastStatus): Unit { + return this.function(value) + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniEventMapSpec.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniEventMapSpec.kt index cd02b1a..1d053e9 100644 --- a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniEventMapSpec.kt +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniEventMapSpec.kt @@ -82,6 +82,24 @@ abstract class HybridOmniEventMapSpec: HybridObject() { return __result } + abstract fun addCastStatusListener(cb: (value: CastStatus) -> Unit): Unit + + @DoNotStrip + @Keep + private fun addCastStatusListener_cxx(cb: Func_void_CastStatus): Unit { + val __result = addCastStatusListener(cb) + return __result + } + + abstract fun removeCastStatusListener(cb: (value: CastStatus) -> Unit): Unit + + @DoNotStrip + @Keep + private fun removeCastStatusListener_cxx(cb: Func_void_CastStatus): Unit { + val __result = removeCastStatusListener(cb) + return __result + } + abstract fun addOnEndListener(cb: () -> Unit): Unit @DoNotStrip diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerFactorySpec.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerFactorySpec.kt index 46211a4..34c5206 100644 --- a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerFactorySpec.kt +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerFactorySpec.kt @@ -30,7 +30,7 @@ abstract class HybridOmniPlayerFactorySpec: HybridObject() { // Methods @DoNotStrip @Keep - abstract fun createPlayer(props: Source?, backend: PlayerBackend?): HybridOmniPlayerSpec + abstract fun createPlayer(props: Source?, backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec // Default implementation of `HybridObject.toString()` override fun toString(): String { diff --git a/nitrogen/generated/shared/c++/CastOptions.hpp b/nitrogen/generated/shared/c++/CastOptions.hpp new file mode 100644 index 0000000..ce87273 --- /dev/null +++ b/nitrogen/generated/shared/c++/CastOptions.hpp @@ -0,0 +1,84 @@ +/// +/// CastOptions.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include + +namespace margelo::nitro::omni { + + /** + * A struct which can be represented as a JavaScript object (CastOptions). + */ + struct CastOptions final { + public: + std::optional receiverApplicationId SWIFT_PRIVATE; + + public: + CastOptions() = default; + explicit CastOptions(std::optional receiverApplicationId): receiverApplicationId(receiverApplicationId) {} + + public: + friend bool operator==(const CastOptions& lhs, const CastOptions& rhs) = default; + }; + +} // namespace margelo::nitro::omni + +namespace margelo::nitro { + + // C++ CastOptions <> JS CastOptions (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::omni::CastOptions fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::omni::CastOptions( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::omni::CastOptions& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"), JSIConverter>::toJSI(runtime, arg.receiverApplicationId)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.cpp b/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.cpp index 55e5c2f..bf38307 100644 --- a/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.cpp +++ b/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.cpp @@ -20,6 +20,8 @@ namespace margelo::nitro::omni { prototype.registerHybridMethod("removeStateBoolListener", &HybridOmniEventMapSpec::removeStateBoolListener); prototype.registerHybridMethod("addPlayerStatusListener", &HybridOmniEventMapSpec::addPlayerStatusListener); prototype.registerHybridMethod("removePlayerStatusListener", &HybridOmniEventMapSpec::removePlayerStatusListener); + prototype.registerHybridMethod("addCastStatusListener", &HybridOmniEventMapSpec::addCastStatusListener); + prototype.registerHybridMethod("removeCastStatusListener", &HybridOmniEventMapSpec::removeCastStatusListener); prototype.registerHybridMethod("addOnEndListener", &HybridOmniEventMapSpec::addOnEndListener); prototype.registerHybridMethod("removeOnEndListener", &HybridOmniEventMapSpec::removeOnEndListener); prototype.registerHybridMethod("addOnPrevListener", &HybridOmniEventMapSpec::addOnPrevListener); diff --git a/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.hpp b/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.hpp index 6a0e837..7053123 100644 --- a/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridOmniEventMapSpec.hpp @@ -19,6 +19,8 @@ namespace margelo::nitro::omni { enum class NumberProperty; } namespace margelo::nitro::omni { enum class BoolProperty; } // Forward declaration of `PlayerStatus` to properly resolve imports. namespace margelo::nitro::omni { enum class PlayerStatus; } +// Forward declaration of `CastStatus` to properly resolve imports. +namespace margelo::nitro::omni { enum class CastStatus; } // Forward declaration of `Track` to properly resolve imports. namespace margelo::nitro::omni { struct Track; } // Forward declaration of `Rendition` to properly resolve imports. @@ -28,6 +30,7 @@ namespace margelo::nitro::omni { struct Rendition; } #include #include "BoolProperty.hpp" #include "PlayerStatus.hpp" +#include "CastStatus.hpp" #include #include "Track.hpp" #include @@ -70,6 +73,8 @@ namespace margelo::nitro::omni { virtual void removeStateBoolListener(BoolProperty key, const std::function& cb) = 0; virtual void addPlayerStatusListener(const std::function& cb) = 0; virtual void removePlayerStatusListener(const std::function& cb) = 0; + virtual void addCastStatusListener(const std::function& cb) = 0; + virtual void removeCastStatusListener(const std::function& cb) = 0; virtual void addOnEndListener(const std::function& cb) = 0; virtual void removeOnEndListener(const std::function& cb) = 0; virtual void addOnPrevListener(const std::function& cb) = 0; diff --git a/nitrogen/generated/shared/c++/HybridOmniPlayerFactorySpec.hpp b/nitrogen/generated/shared/c++/HybridOmniPlayerFactorySpec.hpp index a6d9752..d192a0b 100644 --- a/nitrogen/generated/shared/c++/HybridOmniPlayerFactorySpec.hpp +++ b/nitrogen/generated/shared/c++/HybridOmniPlayerFactorySpec.hpp @@ -19,12 +19,15 @@ namespace margelo::nitro::omni { class HybridOmniPlayerSpec; } namespace margelo::nitro::omni { struct Source; } // Forward declaration of `PlayerBackend` to properly resolve imports. namespace margelo::nitro::omni { struct PlayerBackend; } +// Forward declaration of `CastOptions` to properly resolve imports. +namespace margelo::nitro::omni { struct CastOptions; } #include #include "HybridOmniPlayerSpec.hpp" #include "Source.hpp" #include #include "PlayerBackend.hpp" +#include "CastOptions.hpp" namespace margelo::nitro::omni { @@ -57,7 +60,7 @@ namespace margelo::nitro::omni { public: // Methods - virtual std::shared_ptr createPlayer(const std::optional& props, const std::optional& backend) = 0; + virtual std::shared_ptr createPlayer(const std::optional& props, const std::optional& backend, const std::optional& cast) = 0; protected: // Hybrid Setup diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 9b42643..1a725f4 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,15 +1,14 @@ import type { ConfigPlugin } from "@expo/config-plugins"; import { createRunOncePlugin } from "@expo/config-plugins"; import pkg from "../../package.json"; +import { withCast } from "./withCast"; import { withMediaNotifications } from "./withMediaNotifications"; import { withPip } from "./withPip"; const withOmni: ConfigPlugin = (config) => { - let nextConfig = withPip(config); - nextConfig = withMediaNotifications(nextConfig); - return nextConfig; + return withCast(withMediaNotifications(withPip(config))); }; export default createRunOncePlugin(withOmni, pkg.name, pkg.version); -export { withMediaNotifications, withPip }; +export { withCast, withMediaNotifications, withPip }; diff --git a/plugin/src/withCast.ts b/plugin/src/withCast.ts new file mode 100644 index 0000000..23c2da9 --- /dev/null +++ b/plugin/src/withCast.ts @@ -0,0 +1,47 @@ +import { + AndroidConfig, + type ConfigPlugin, + withAndroidManifest, +} from "@expo/config-plugins"; + +const OPTIONS_PROVIDER_NAME = + "com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"; +const OPTIONS_PROVIDER_VALUE = "dev.zoriya.omni.OmniCastOptionsProvider"; + +function patchManifestForCast( + androidManifest: AndroidConfig.Manifest.AndroidManifest, +): AndroidConfig.Manifest.AndroidManifest { + const mainApplication = + AndroidConfig.Manifest.getMainApplication(androidManifest); + if (!mainApplication) { + console.warn( + "AndroidManifest.xml is missing a element - skipping Omni cast config.", + ); + return androidManifest; + } + + const metaData = mainApplication["meta-data"] ?? []; + const existing = metaData.find( + (item) => item.$["android:name"] === OPTIONS_PROVIDER_NAME, + ); + if (existing) { + existing.$["android:value"] = OPTIONS_PROVIDER_VALUE; + } else { + metaData.push({ + $: { + "android:name": OPTIONS_PROVIDER_NAME, + "android:value": OPTIONS_PROVIDER_VALUE, + }, + }); + } + mainApplication["meta-data"] = metaData; + + return androidManifest; +} + +export const withCast: ConfigPlugin = (config) => { + return withAndroidManifest(config, (manifestConfig) => { + manifestConfig.modResults = patchManifestForCast(manifestConfig.modResults); + return manifestConfig; + }); +}; diff --git a/src/events.tsx b/src/events.tsx index c820a90..4e8518f 100644 --- a/src/events.tsx +++ b/src/events.tsx @@ -53,8 +53,8 @@ export function usePlayerState( em.addPlayerStatusListener(setState); return () => em.removePlayerStatusListener(setState); case "castStatus": - // TODO: implement this - return; + em.addCastStatusListener(setState); + return () => em.removeCastStatusListener(setState); } }, [player, key]); diff --git a/src/provider.tsx b/src/provider.tsx index 986947c..2f9a1a1 100755 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -15,7 +15,7 @@ export const OmniProvider = ({ source, backend = { android: "vlc" }, showNotification = false, - cast: _, + cast, }: { source?: Source; cast?: CastOptions; @@ -24,7 +24,7 @@ export const OmniProvider = ({ showNotification?: boolean; }) => { const player = useLazyRef(() => - ProviderFactory.createPlayer(source, backend), + ProviderFactory.createPlayer(source, backend, cast), ); useEffect(() => { diff --git a/src/specs/omni-player.nitro.ts b/src/specs/omni-player.nitro.ts index 2010353..dcd00ef 100644 --- a/src/specs/omni-player.nitro.ts +++ b/src/specs/omni-player.nitro.ts @@ -1,12 +1,13 @@ import type { HybridObject } from "react-native-nitro-modules"; import type { OmniEvents } from "../types/events"; import type { + CastStatus, OmniPlayerState, OmniPlayer as OmniPlayerT, PlayerBackend, PlayerStatus, } from "../types/player"; -import type { Source } from "../types/source"; +import type { CastOptions, Source } from "../types/source"; export type NumberProperty = Exclude< keyof OmniPlayerState, @@ -25,6 +26,9 @@ export interface OmniEventMap extends HybridObject<{ android: "kotlin" }> { addPlayerStatusListener(cb: (value: PlayerStatus) => void): void; removePlayerStatusListener(cb: (value: PlayerStatus) => void): void; + addCastStatusListener(cb: (value: CastStatus) => void): void; + removeCastStatusListener(cb: (value: CastStatus) => void): void; + addOnEndListener(cb: OmniEvents["end"]): void; removeOnEndListener(cb: OmniEvents["end"]): void; @@ -60,5 +64,9 @@ export interface OmniPlayer } export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> { - createPlayer(props?: Source, backend?: PlayerBackend): OmniPlayer; + createPlayer( + props?: Source, + backend?: PlayerBackend, + cast?: CastOptions, + ): OmniPlayer; }