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) }