Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,25 @@
</intent-filter>

</activity>

<!-- Declares voice "play from search" support. Android Auto / Assistant only route a
spoken MEDIA_PLAY_FROM_SEARCH command to the MediaSession (onPlayFromSearch) when the
app declares an activity with this *bare* intent filter (no data constraints) - see
android/uamp#479. This activity also directly handles the legacy activity-style
MEDIA_PLAY_FROM_SEARCH intent (e.g. from automation tools) by searching and playing. -->
<activity
android:name=".PlayMediaFromSearchActivity"
android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<service
android:name=".RouterActivity$FetcherService"
android:foregroundServiceType="dataSync"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.schabi.newpipe

import android.app.Activity
import android.app.SearchManager
import android.content.Intent
import android.os.Bundle
import android.util.Log
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ServiceHelper

/**
* Handles the "Play <something>" 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<StreamInfoItem>()
.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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <something> 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<StreamInfoItem>()
.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?) {
Expand Down Expand Up @@ -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<PlayQueue> {
return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
.map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
Expand Down
Loading