package dev.zoriya.omni import android.annotation.SuppressLint import android.content.Context import android.os.Looper import android.view.KeyEvent import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.Handler import androidx.core.app.NotificationCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.common.Tracks import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import com.margelo.nitro.NitroModules import com.margelo.nitro.omni.AndroidBackend import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.HybridOmniPlayerSpec import com.margelo.nitro.omni.Metadata import com.margelo.nitro.omni.MixAudioMode import com.margelo.nitro.omni.PlayerStatus import com.margelo.nitro.omni.Rendition import com.margelo.nitro.omni.Source import com.margelo.nitro.omni.Track import com.margelo.nitro.omni.VideoSrc 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.MediaSession import androidx.media3.session.MediaSessionService import androidx.mediarouter.app.MediaRouteChooserDialog import androidx.mediarouter.app.SystemOutputSwitcherDialogController import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaStatus 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, castOptions: CastOptions? = null, ) : HybridOmniPlayerSpec(), TrackProvider { private val ctx = NitroModules.applicationContext ?: throw Error("No Context available!") // exoplayer only, used to specify request headers. private var httpDataSourceFactory: DefaultHttpDataSource.Factory? = null val localPlayer: Player = runOnMainThreadSync { when (backend) { AndroidBackend.EXOPLAYER -> { val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) httpDataSourceFactory = http // karaoke subs make exoplayer crash without this... val renderersFactory = object : DefaultRenderersFactory(ctx) { override fun buildTextRenderers( context: Context, output: TextOutput, outputLooper: Looper, extensionRendererMode: Int, out: ArrayList, ) { out.add( TextRenderer(output, outputLooper).apply { experimentalSetLegacyDecodingEnabled(true) } ) } } ExoPlayer.Builder(ctx, renderersFactory) .setMediaSourceFactory( DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http)) .experimentalParseSubtitlesDuringExtraction(false) ) .build() } AndroidBackend.VLC -> VlcPlayer(ctx) } } override val eventMap = EventMap(this) private var castContext: CastContext? = null private val castStateListener = CastStateListener { eventMap.emitCastStatus(computeCastStatus()) listenToReceiver() syncNotificationService() // we were only kept alive to hold this cast, it is over now if (abandoned && !isCasting) release() } // receivers have no queue to skip in (a queue would make them play things on // their own), so they ask us to do it when something else - google home, the // assistant, ... - requests a prev/next. same path as our own buttons. private fun listenToReceiver() { val session = castContext?.sessionManager?.currentCastSession eventMap.remote = session?.remoteMediaClient if (session == null) return try { session.setMessageReceivedCallbacks(OMNI_NAMESPACE) { _, _, message -> when (JSONObject(message).optString("action")) { "prev" -> runOnMainThread { eventMap.emitPrev() } "next" -> runOnMainThread { eventMap.emitNext() } } } } catch (e: Throwable) { android.util.Log.w("OmniPlayer", "could not listen to the receiver", e) } } // media3's `RemoteCastPlayer` loads with a `LOAD` that only holds a `queueData`. // on android tv (cast connect), the tv hands a `LOAD` to the app as an intent built // from its top-level `media` and silently drops it when there is none: the app is // launched but never receives the media. `RemoteCastPlayer` is final and gives no // hook on the request, so we send a regular `LOAD` ourselves (web receivers take it // too). media3 still follows the receiver's queue from its status. // see: https://github.com/androidx/media/issues/3462 private fun castLoad(item: MediaItem, positionMs: Long, autoplay: Boolean) { val remote = castContext?.sessionManager?.currentCastSession?.remoteMediaClient ?: return remote.load( MediaLoadRequestData.Builder() .setMediaInfo(OmniMediaItemConverter().toMediaQueueItem(item).media) .setAutoplay(autoplay) .setCurrentTime(positionMs) .build() ) } // media3 rebuilds the receiver's items with `OmniMediaItemConverter.toMediaItem`, // match them with ours by their cast content id. private fun isOwnItem(item: MediaItem?): Boolean { val own = ownItem ?: return false if (item == null) return false if (item == own) return true val ownId = own.requestMetadata.extras?.getString(CAST_ID_EXTRA) ?: own.localConfiguration?.uri?.toString() return item.mediaId == ownId } val player: Player = runOnMainThreadSync { castOptions?.receiverApplicationId?.let { receiverApplicationId = it } notificationUrl = castOptions?.notificationUrl 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) .setTransferCallback { from, to -> val item = ownItem // a cast that ends (stopped, tv turned off...) leaves the media paused val play = from.playWhenReady && to !== localPlayer when { item == null -> to.playWhenReady = play to === localPlayer -> { // keep our item (headers, subtitles...) over the rebuilt one. a // receiver that went away (killed, network lost) has none left. if (from.currentMediaItem == null || isOwnItem(from.currentMediaItem)) { to.setMediaItems(listOf(item), 0, from.currentPosition) to.prepare() } else { CastPlayer.TransferCallback.DEFAULT.transferState(from, to) } to.playWhenReady = play } else -> castLoad(item, from.currentPosition, from.playWhenReady) } } .build() } NavigationPlayer( active, hasPrev = { source?.metadata?.hasPrev == true }, hasNext = { source?.metadata?.hasNext == true }, onPrev = { eventMap.emitPrev() }, onNext = { eventMap.emitNext() }, ) } override var showNotification: Boolean? = false set(value) { field = value syncNotificationService() } private var serviceRunning = false private fun syncNotificationService() { val shouldShow = runOnMainThreadSync { isCasting } || (!abandoned && showNotification == true && source != null) when { shouldShow && !serviceRunning -> { val otherIsPlaying = notificationPlayer?.let { other -> // an abandoned one only holds it until the cast it kept alive ends other !== this && !other.abandoned && runOnMainThreadSync { other.player.isPlaying } } == true if (otherIsPlaying) { throw Error("Two players can't display notifications at the same time.") } notificationPlayer = this // output switcher when app is closed can't startForeground try { ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java)) serviceRunning = true } catch (e: Throwable) { android.util.Log.w("OmniPlayer", "could not start the playback service", e) notificationPlayer = null } } !shouldShow && serviceRunning -> { ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) if (notificationPlayer === this) notificationPlayer = null serviceRunning = false } } } override val castStatus: CastStatus get() = runOnMainThreadSync { computeCastStatus() } val isCasting: Boolean get() = castContext?.sessionManager?.currentCastSession?.isConnected == true 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() { 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 if (SystemOutputSwitcherDialogController.showDialog(activity)) { return@runOnMainThread } val selector = cc.mergedSelector ?: return@runOnMainThread MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show() } } // the app let go of us. while casting we stay alive anyway: OmniPlayerService holds the // process (and thus the session) up, and its notification is the only remote left - we // keep owning both until castStateListener sees the cast end. @Volatile private var abandoned = false override fun abandon() { if (abandoned) return abandoned = true if (!runOnMainThreadSync { isCasting }) release() } // drop everything. this ends the cast session, so it only runs once there is no cast // left to hold (either there was none, or it just ended). private fun release() { runOnMainThread { castContext?.removeCastStateListener(castStateListener) if (notificationPlayer === this) { // syncNotificationService ignores us now, hand the service back ourselves ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) notificationPlayer = null serviceRunning = false } eventMap.dispose() player.release() } } override fun dispose() { abandon() super.dispose() } private fun inferSubtitleMimeType(link: String): String? { val ext = link.toUri().lastPathSegment?.substringAfterLast('.', "")?.lowercase() return when (ext) { "vtt", "webvtt" -> MimeTypes.TEXT_VTT "srt" -> MimeTypes.APPLICATION_SUBRIP "ass", "ssa" -> MimeTypes.TEXT_SSA "ttml", "dfxp", "xml" -> MimeTypes.APPLICATION_TTML "sup" -> MimeTypes.APPLICATION_PGS "sub" -> MimeTypes.APPLICATION_VOBSUB else -> null } } private fun buildMediaItem( src: com.margelo.nitro.omni.VideoSrc, metadata: com.margelo.nitro.omni.Metadata?, subtitles: Array, castId: String? = null, castData: Map? = null, ): MediaItem { return MediaItem.Builder() .setUri(src.uri) .setMimeType(src.mimeType) .setMediaId(src.uri) .setMediaMetadata( MediaMetadata.Builder() .setTitle(metadata?.title) .setAlbumTitle(metadata?.album) .setArtist(metadata?.artist) .apply { metadata?.imageLink?.let { setArtworkUri(it.toUri()) } } .build()) .setSubtitleConfigurations(subtitles.map { subtitle -> SubtitleConfiguration.Builder(subtitle.link.toUri()) .setId(subtitle.id) .setLanguage(subtitle.language) .setLabel(subtitle.label) // exoplayer requires a mime type to pick a subtitle parser. // fall back to inferring it from the link's extension so // consumers don't have to provide one. .setMimeType(subtitle.mimeType ?: inferSubtitleMimeType(subtitle.link)) .build() }) .setRequestMetadata( RequestMetadata.Builder() .setMediaUri(src.uri.toUri()) .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() } private fun sourceOf(item: MediaItem): Source { val meta = item.mediaMetadata val status = castContext?.sessionManager?.currentCastSession ?.remoteMediaClient?.mediaStatus return Source( src = VideoSrc( uri = item.localConfiguration?.uri?.toString() ?: item.mediaId, mimeType = item.localConfiguration?.mimeType, headers = emptyMap(), ), startTime = null, subtitles = emptyArray(), fonts = null, metadata = Metadata( title = meta.title?.toString() ?: "", album = meta.albumTitle?.toString(), artist = meta.artist?.toString(), imageLink = meta.artworkUri?.toString(), hasPrev = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_PREVIOUS), hasNext = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT), ), mixAudio = null, castId = item.mediaId, castData = null, ) } fun setVideoView(surfaceView: android.view.SurfaceView) { runOnMainThread { localPlayer.setVideoSurfaceView(surfaceView) } } fun clearVideoView(surfaceView: android.view.SurfaceView) { runOnMainThread { localPlayer.clearVideoSurfaceView(surfaceView) } } // rebuild video pipeline after pip, noop for exoplayer that recover on it's own. fun rebuildVideoOutput() { runOnMainThread { (localPlayer as? VlcPlayer)?.rebuildVideoOutput() } } fun updateVideoLayout(width: Int, height: Int) { runOnMainThread { (localPlayer as? VlcPlayer)?.updateVideoLayout(width, height) } } override val hasPrev: Boolean get() = source?.metadata?.hasPrev == true override val hasNext: Boolean get() = source?.metadata?.hasNext == true override val status by mainThreadProperty { when (player.playbackState) { Player.STATE_IDLE, Player.STATE_ENDED -> PlayerStatus.IDLE Player.STATE_BUFFERING -> PlayerStatus.LOADING else -> PlayerStatus.READYTOPLAY } } override val isPlaying by mainThreadProperty { player.playWhenReady } override var currentTime by mainThreadProperty( get = { player.currentPosition.toDouble() / 1000.0 }, set = { value -> player.seekTo((value.coerceAtLeast(0.0) * 1000.0).toLong()) } ) override val buffered by mainThreadProperty { (player.totalBufferedDuration.toDouble() / 1000.0).coerceAtLeast(0.0) } override val duration by mainThreadProperty { if (player.duration == C.TIME_UNSET) 0.0 else (player.duration.toDouble() / 1000.0).coerceAtLeast( 0.0 ) } override var playbackRate by mainThreadProperty( get = { player.playbackParameters.speed.toDouble() }, set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0.01f)) } ) override var muted by mainThreadProperty( get = { player.volume <= 0f }, set = { value -> if (value) player.mute() else player.unmute() } ) override var volume by mainThreadProperty( get = { player.volume.toDouble() }, set = { value -> player.volume = value.toFloat().coerceIn(0f, 1f) } ) override val videos by mainThreadProperty { tracksByType(C.TRACK_TYPE_VIDEO) } override val audios by mainThreadProperty { tracksByType(C.TRACK_TYPE_AUDIO) } override val subtitles by mainThreadProperty { tracksByType(C.TRACK_TYPE_TEXT) } override val renditions by mainThreadProperty { val group = player.currentTracks.groups.firstOrNull { it.isSelected && it.type == C.TRACK_TYPE_VIDEO } ?: return@mainThreadProperty emptyArray() val currentIndex = when { isAutoQuality -> { if (player.videoSize.width > 0 && player.videoSize.height > 0) { (0 until group.length).firstOrNull { i -> val format = group.getTrackFormat(i) format.width == player.videoSize.width && format.height == player.videoSize.height } } else null } else -> (0 until group.length).firstOrNull { group.isTrackSelected(it) } } val result = ArrayList() for (i in 0 until group.length) { val format = group.getTrackFormat(i) result.add( Rendition( id = i.toString(), width = format.width.toDouble().coerceAtLeast(0.0), height = format.height.toDouble().coerceAtLeast(0.0), bitrate = format.bitrate.toDouble().coerceAtLeast(0.0), selected = i == currentIndex ) ) } return@mainThreadProperty result.toTypedArray() } override var isAutoQuality by mainThreadProperty { player.trackSelectionParameters.overrides.none { it.key.type == C.TRACK_TYPE_VIDEO } } private var _source: Source? = null private var ownItem: MediaItem? = null private var ownSource: Source? = null init { runOnMainThreadSync { eventMap.player = player player.addListener(object : Player.Listener { override fun onMediaItemTransition(item: MediaItem?, reason: Int) = followMedia(item) override fun onTimelineChanged(timeline: Timeline, reason: Int) = followMedia(player.currentMediaItem) override fun onTracksChanged(tracks: Tracks) = OmniCastReceiver.broadcastTracks(this@OmniPlayer) }) // check for cast rejoin followMedia(player.currentMediaItem) syncNotificationService() } } private fun followMedia(media: MediaItem?) { // media3 only knows the items it loaded, it has an empty one for a media the // receiver loaded on its own (an android tv app playing its next episode...) val info = eventMap.remote?.mediaInfo val item = if (media != null && media.localConfiguration == null && isCasting && info != null) { OmniMediaItemConverter().toMediaItem(MediaQueueItem.Builder(info).build()) } else { media } if (item == null && ownItem != null) { // our media only leaves the player while it is being handed over (to the // receiver when a cast starts, back to us when it ends), it is not gone. // it only is when the receiver dropped it while we are still connected to // it: someone stopped it (google home, the tv, ...) val dropped = eventMap.remote?.mediaStatus?.idleReason == MediaStatus.IDLE_REASON_CANCELED if (!isCasting || !dropped) return ownItem = null ownSource = null // there is nothing left for us to drive there, leave the receiver. posted: // we are in the middle of media3's status update. Handler(Looper.getMainLooper()).post { castContext?.sessionManager?.endCurrentSession(false) } } if (isOwnItem(item)) { // the receiver can report what it played before ours while it loads it if (_source !== ownSource) { _source = ownSource eventMap.emitSource(ownSource) } return } val source = item?.let { sourceOf(it) } if (source == _source) return _source = source eventMap.emitSource(source) syncNotificationService() } override var source: Source? get() = _source set(value) { _source = value eventMap.emitSource(value) if (value == null) { ownItem = null ownSource = null runOnMainThreadSync { OmniCastReceiver.stopMedia(this) player.clearMediaItems() } syncNotificationService() return } val handleAudioFocus = (value.mixAudio ?: MixAudioMode.AUTO) != MixAudioMode.MIXWITHOTHERS val audioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() // audio focus only makes sense locally. not in a cast session runOnMainThread { localPlayer.setAudioAttributes(audioAttributes, handleAudioFocus) } val src = value.src httpDataSourceFactory?.setDefaultRequestProperties(src.headers) val currentItem = buildMediaItem( src, value.metadata, value.subtitles, value.castId, value.castData, ) ownItem = currentItem ownSource = value runOnMainThreadSync { val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0 if (player.deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { castLoad(currentItem, startPositionMs.toLong(), player.playWhenReady) } else { player.setMediaItems(listOf(currentItem), 0, startPositionMs.toLong()) player.prepare() } OmniCastReceiver.broadcastMedia( this, currentItem, hasPrev = value.metadata?.hasPrev == true, hasNext = value.metadata?.hasNext == true, ) } syncNotificationService() } override fun play() { runOnMainThreadSync { player.play() } } override fun pause() { runOnMainThreadSync { player.pause() } } override fun seekBy(offset: Double) { runOnMainThreadSync { val target = (player.currentPosition.toDouble() / 1000.0) + offset player.seekTo((target.coerceAtLeast(0.0) * 1000.0).toLong()) } } override fun playPrev() { runOnMainThreadSync { eventMap.emitPrev() } } override fun playNext() { runOnMainThreadSync { eventMap.emitNext() } } override fun selectVideo(video: Track) { runOnMainThreadSync { player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() .setPreferredVideoLanguage(video.language) .setPreferredVideoLabels(*(video.label?.let { arrayOf(it) } ?: emptyArray())) .build() } } override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio) override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle) private fun selectTrack(type: Int, track: Track?) { runOnMainThreadSync { val params = player.trackSelectionParameters.buildUpon() .clearOverridesOfType(type) if (track == null) { params.setTrackTypeDisabled(type, true) } else { params.setTrackTypeDisabled(type, false) val group = player.currentTracks.groups.firstOrNull { it.type == type && it.mediaTrackGroup.id == track.id }?.mediaTrackGroup if (group != null) { params.setOverrideForType(TrackSelectionOverride(group, 0)) } // also set preferred flags for cast selection val labels = track.label?.let { arrayOf(it) } ?: emptyArray() if (type == C.TRACK_TYPE_AUDIO) { params.setPreferredAudioLanguage(track.language) .setPreferredAudioLabels(*labels) } else { params.setPreferredTextLanguage(track.language) .setPreferredTextLabels(*labels) } } player.trackSelectionParameters = params.build() } } private fun tracksByType(trackType: Int): Array { val groups = player.currentTracks.groups.filter { it.type == trackType } if (groups.isEmpty()) return emptyArray() // when casting tracks are stripped of some metadata, recover it val castTracks = castMediaTracksById() // external (sideloaded) subs aren't in the container, so their index is -1 val sideloadedIds = source?.subtitles?.map { it.id }?.toHashSet().orEmpty() var index = 0 return groups.map { group -> val format = group.getTrackFormat(0) val id = group.mediaTrackGroup.id val castTrackId = id.substringAfterLast("track=", "").toLongOrNull() val cast = castTracks[castTrackId] val sideloaded = trackType == C.TRACK_TYPE_TEXT && id in sideloadedIds Track( id = id, index = if (sideloaded) -1.0 else (index++).toDouble(), label = cast?.name ?: format.label, language = cast?.language ?: format.language, selected = group.isSelected ) }.toTypedArray() } private fun castMediaTracksById(): Map { if (!isCasting) return emptyMap() val tracks = castContext?.sessionManager?.currentCastSession ?.remoteMediaClient?.mediaInfo?.mediaTracks ?: return emptyMap() return tracks.associateBy { it.id } } override fun selectRendition(rendition: Rendition?) { runOnMainThreadSync { if (rendition == null) { player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() .clearOverridesOfType(C.TRACK_TYPE_VIDEO) .build() return@runOnMainThreadSync } val group = player.currentTracks.groups.find { it.isSelected && it.type == C.TRACK_TYPE_VIDEO } ?: return@runOnMainThreadSync player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() .setOverrideForType( TrackSelectionOverride( group.mediaTrackGroup, rendition.id.toInt() ) ) .build() } } companion object { var notificationPlayer: OmniPlayer? = null // 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" // channel a receiver uses to ask us to play the prev/next media. // it must declare it too (`customNamespaces` in the caf options). const val OMNI_NAMESPACE = "urn:x-cast:dev.zoriya.omni" // Receiver application id passed at runtime via OmniProvider's `cast` // prop; read by OmniCastOptionsProvider when the Cast SDK initializes. var receiverApplicationId: String? = null // `cast.notificationUrl`, opened when our notification is tapped. var notificationUrl: String? = null } } @SuppressLint("UnsafeOptInUsageError") class OmniPlayerService : MediaSessionService() { lateinit var player: Player lateinit var mediaSession: MediaSession override fun onCreate() { super.onCreate() startForeground(1, createImmediateNotification()) val available = OmniPlayer.notificationPlayer?.player if (available == null) { stopSelf() return } player = available val sessionActivity = openIntent() mediaSession = try { MediaSession.Builder(this, player) .apply { sessionActivity?.let { setSessionActivity(it) } } .setCallback(object : MediaSession.Callback { override fun onMediaButtonEvent( session: MediaSession, controllerInfo: MediaSession.ControllerInfo, intent: Intent, ): Boolean { @Suppress("DEPRECATION") val key = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) // media3 hangs a synthetic MEDIA_STOP off the notification's delete // intent, so that swiping the notification away ends playback. A tv has // nowhere to put a media notification and drops ours the moment it is // posted, firing that intent and tearing the player down a few hundred // ms in. A stop we actually want - a headset, the assistant - comes from // an input device; this one has none. if (isTv && key?.keyCode == KeyEvent.KEYCODE_MEDIA_STOP && key.deviceId == -1) { return true } return false } }) .build() } catch (e: Throwable) { android.util.Log.w("OmniPlayerService", "could not open a media session", e) stopSelf() return } addSession(mediaSession) OmniCastReceiver.setSession(mediaSession) setShowNotificationForIdlePlayer(SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS) triggerNotificationUpdate() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(1, createImmediateNotification()) val current = OmniPlayer.notificationPlayer?.player if (current != null && ::mediaSession.isInitialized && mediaSession.player !== current) { player = current mediaSession.player = current } super.onStartCommand(intent, flags, startId) return START_NOT_STICKY } private fun createImmediateNotification(): Notification { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( "omni_playback", "Playback", NotificationManager.IMPORTANCE_LOW ) val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } return NotificationCompat.Builder(this, "omni_playback") .setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon) .setContentTitle("Omni Player") .setContentText("Preparing playback...") .setContentIntent(openIntent()) .setOngoing(true) .build() } private fun openIntent(): PendingIntent? { val intent = OmniPlayer.notificationUrl ?.let { Intent(Intent.ACTION_VIEW, it.toUri()).setPackage(packageName) } ?: packageManager.getLaunchIntentForPackage(packageName) ?: return null intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) return PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = if (::mediaSession.isInitialized) mediaSession else null override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { super.onUpdateNotification(session, startInForegroundRequired || isCasting()) } private val isTv by lazy { packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) } private fun isCasting() = try { CastContext.getSharedInstance(this) .sessionManager.currentCastSession?.isConnected == true } catch (_: Throwable) { false } override fun onTaskRemoved(rootIntent: Intent?) { // keep service open if casting if (!isCasting()) pauseAllPlayersAndStopSelf() } override fun onDestroy() { if (::mediaSession.isInitialized) { OmniCastReceiver.setSession(null) mediaSession.release() } super.onDestroy() } }