diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67a33742baf..6e7ebb0c0e7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -429,6 +429,25 @@ + + + + + + + + + " voice intent ([android.media.action.MEDIA_PLAY_FROM_SEARCH]) when + * it arrives as a plain text query (i.e. without a media URL), e.g. from Google Assistant or an + * automation tool. + * + * Declaring this activity with a bare MEDIA_PLAY_FROM_SEARCH intent filter is also what makes + * Android Auto route spoken play-from-search commands to our MediaSession (see + * MediaBrowserPlaybackPreparer.onPrepareFromSearch); Auto uses the session path and does not + * actually launch this activity. + * + * When launched directly, it searches the user's selected service and starts background playback of + * the first matching stream, falling back to the search results screen if nothing is found. + * + * It has no UI of its own (translucent theme): it kicks off playback and finishes. + */ +class PlayMediaFromSearchActivity : Activity() { + private var searchDisposable: Disposable? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val query = intent?.getStringExtra(SearchManager.QUERY) + ?: intent?.getStringExtra(Intent.EXTRA_TEXT) + + if (query.isNullOrBlank()) { + // Nothing to search for; just open the app rather than failing silently. + NavigationHelper.openMainActivity(this) + finish() + return + } + + val serviceId = ServiceHelper.getSelectedServiceId(this) + + searchDisposable = ExtractorHelper.searchFor(serviceId, query, emptyList(), "") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { searchInfo -> + val firstStream = searchInfo.relatedItems + .filterIsInstance() + .firstOrNull() + if (firstStream == null) { + // No playable result: show the search results screen as a fallback. + NavigationHelper.openSearch(this, serviceId, query) + } else { + NavigationHelper.playOnBackgroundPlayer( + this, + SinglePlayQueue(firstStream), + true + ) + } + finish() + }, + { throwable -> + Log.e(TAG, "Failed to play from search query [$query]", throwable) + // Don't dead-end the user: fall back to the search results screen. + NavigationHelper.openSearch(this, serviceId, query) + finish() + } + ) + } + + override fun onDestroy() { + super.onDestroy() + searchDisposable?.dispose() + } + + companion object { + private val TAG = PlayMediaFromSearchActivity::class.java.simpleName + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index c0a2f966818..62c2ee93f1e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -23,6 +23,7 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.extractor.InfoItem.InfoType import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler +import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.local.playlist.RemotePlaylistManager import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue @@ -32,6 +33,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.util.ChannelTabHelper import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.ServiceHelper /** * This class is used to cleanly separate the Service implementation (in @@ -62,7 +64,13 @@ class MediaBrowserPlaybackPreparer( //region Overrides override fun getSupportedPrepareActions(): Long { - return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + // Advertise both PLAY_FROM and PREPARE_FROM variants (UAMP pattern): some controllers send + // prepareFromSearch instead of playFromSearch, and the connector drops it unless the + // matching action is advertised. + return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH } override fun onPrepare(playWhenReady: Boolean) { @@ -91,7 +99,50 @@ class MediaBrowserPlaybackPreparer( } override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - onUnsupportedError() + if (MainActivity.DEBUG) { + Log.d(TAG, "onPrepareFromSearch($query, $playWhenReady, $extras)") + } + + // An empty query (e.g. a bare "play music" voice command, which Android Auto / AAOS can + // send) should start something rather than error out: returning ERROR_CODE_NOT_SUPPORTED + // can make the voice agent treat the app as not search-capable. Resume the most recently + // played stream instead. + if (query.isBlank()) { + playMostRecentlyPlayed(playWhenReady) + return + } + + // Search the user's currently selected service (YouTube by default) and play the first + // stream result, mirroring the "Play on NewPipe" voice intent. + val serviceId = ServiceHelper.getSelectedServiceId(context) + + disposable?.dispose() + disposable = ExtractorHelper.searchFor(serviceId, query, emptyList(), "") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { searchInfo -> + val firstStream = searchInfo.relatedItems + .filterIsInstance() + .firstOrNull() + if (firstStream == null) { + onPrepareError( + ContentNotAvailableException("No streams found for query \"$query\"") + ) + } else { + clearMediaSessionError.run() + NavigationHelper.playOnBackgroundPlayer( + context, + SinglePlayQueue(firstStream), + playWhenReady + ) + } + }, + { throwable -> + Log.e(TAG, "Failed to play from search query [$query]", throwable) + onPrepareError(throwable) + } + ) } override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { @@ -125,6 +176,33 @@ class MediaBrowserPlaybackPreparer( //endregion //region Building play queues from playlists and history + private fun playMostRecentlyPlayed(playWhenReady: Boolean) { + disposable?.dispose() + disposable = database.streamHistoryDAO().history + .firstOrError() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { history -> + val mostRecent = history.firstOrNull()?.toStreamInfoItem() + if (mostRecent == null) { + onUnsupportedError() + } else { + clearMediaSessionError.run() + NavigationHelper.playOnBackgroundPlayer( + context, + SinglePlayQueue(mostRecent), + playWhenReady + ) + } + }, + { throwable -> + Log.e(TAG, "Failed to play most recent stream for empty query", throwable) + onPrepareError(throwable) + } + ) + } + private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single { return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() .map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }