mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2025-06-10 02:24:06 +00:00
Merge pull request #12044 from TeamNewPipe/android-auto
Add support for Android Auto *(season 2)*
This commit is contained in:
commit
196c27792b
@ -64,6 +64,9 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||||
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@ -424,5 +427,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
<!-- Android Auto -->
|
||||||
|
<meta-data android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||||
|
android:resource="@mipmap/ic_launcher" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -862,7 +862,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onReceive(final Context context, final Intent intent) {
|
public void onReceive(final Context context, final Intent intent) {
|
||||||
if (Objects.equals(intent.getAction(),
|
if (Objects.equals(intent.getAction(),
|
||||||
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
|
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
||||||
|
&& PlayerHolder.getInstance().isPlayerOpen()) {
|
||||||
openMiniPlayerIfMissing();
|
openMiniPlayerIfMissing();
|
||||||
// At this point the player is added 100%, we can unregister. Other actions
|
// At this point the player is added 100%, we can unregister. Other actions
|
||||||
// are useless since the fragment will not be removed after that.
|
// are useless since the fragment will not be removed after that.
|
||||||
@ -874,6 +875,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
final IntentFilter intentFilter = new IntentFilter();
|
final IntentFilter intentFilter = new IntentFilter();
|
||||||
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
|
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
|
||||||
registerReceiver(broadcastReceiver, intentFilter);
|
registerReceiver(broadcastReceiver, intentFilter);
|
||||||
|
|
||||||
|
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
||||||
|
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
||||||
|
PlayerHolder.getInstance().tryBindIfNeeded(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Embedded
|
import androidx.room.Embedded
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.util.image.ImageStrategy
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
data class StreamHistoryEntry(
|
data class StreamHistoryEntry(
|
||||||
@ -27,4 +29,17 @@ data class StreamHistoryEntry(
|
|||||||
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
||||||
accessDate.isEqual(other.accessDate)
|
accessDate.isEqual(other.accessDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toStreamInfoItem(): StreamInfoItem =
|
||||||
|
StreamInfoItem(
|
||||||
|
streamEntity.serviceId,
|
||||||
|
streamEntity.url,
|
||||||
|
streamEntity.title,
|
||||||
|
streamEntity.streamType,
|
||||||
|
).apply {
|
||||||
|
duration = streamEntity.duration
|
||||||
|
uploaderName = streamEntity.uploader
|
||||||
|
uploaderUrl = streamEntity.uploaderUrl
|
||||||
|
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package org.schabi.newpipe.database.playlist;
|
package org.schabi.newpipe.database.playlist;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
import org.schabi.newpipe.database.LocalItem;
|
||||||
|
|
||||||
public interface PlaylistLocalItem extends LocalItem {
|
public interface PlaylistLocalItem extends LocalItem {
|
||||||
@ -10,4 +12,7 @@ public interface PlaylistLocalItem extends LocalItem {
|
|||||||
long getUid();
|
long getUid();
|
||||||
|
|
||||||
void setDisplayIndex(long displayIndex);
|
void setDisplayIndex(long displayIndex);
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
String getThumbnailUrl();
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
|
|||||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||||
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
||||||
|
|
||||||
@ -71,4 +73,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
|||||||
public void setDisplayIndex(final long displayIndex) {
|
public void setDisplayIndex(final long displayIndex) {
|
||||||
this.displayIndex = displayIndex;
|
this.displayIndex = displayIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return thumbnailUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
|||||||
|
|
||||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||||
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
|
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
|
||||||
|
|
||||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||||
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||||
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.model;
|
|||||||
|
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.room.ColumnInfo;
|
import androidx.room.ColumnInfo;
|
||||||
import androidx.room.Entity;
|
import androidx.room.Entity;
|
||||||
import androidx.room.Ignore;
|
import androidx.room.Ignore;
|
||||||
@ -134,6 +135,8 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
public String getThumbnailUrl() {
|
public String getThumbnailUrl() {
|
||||||
return thumbnailUrl;
|
return thumbnailUrl;
|
||||||
}
|
}
|
||||||
|
@ -236,11 +236,14 @@ public final class VideoDetailFragment
|
|||||||
// Service management
|
// Service management
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
@Override
|
@Override
|
||||||
public void onServiceConnected(final Player connectedPlayer,
|
public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
|
||||||
final PlayerService connectedPlayerService,
|
|
||||||
final boolean playAfterConnect) {
|
|
||||||
player = connectedPlayer;
|
|
||||||
playerService = connectedPlayerService;
|
playerService = connectedPlayerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerConnected(@NonNull final Player connectedPlayer,
|
||||||
|
final boolean playAfterConnect) {
|
||||||
|
player = connectedPlayer;
|
||||||
|
|
||||||
// It will do nothing if the player is not in fullscreen mode
|
// It will do nothing if the player is not in fullscreen mode
|
||||||
hideSystemUiIfNeeded();
|
hideSystemUiIfNeeded();
|
||||||
@ -272,11 +275,18 @@ public final class VideoDetailFragment
|
|||||||
updateOverlayPlayQueueButtonVisibility();
|
updateOverlayPlayQueueButtonVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerDisconnected() {
|
||||||
|
player = null;
|
||||||
|
// the binding could be null at this point, if the app is finishing
|
||||||
|
if (binding != null) {
|
||||||
|
restoreDefaultBrightness();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onServiceDisconnected() {
|
public void onServiceDisconnected() {
|
||||||
playerService = null;
|
playerService = null;
|
||||||
player = null;
|
|
||||||
restoreDefaultBrightness();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1848,13 +1858,16 @@ public final class VideoDetailFragment
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onServiceStopped() {
|
public void onServiceStopped() {
|
||||||
setOverlayPlayPauseImage(false);
|
// the binding could be null at this point, if the app is finishing
|
||||||
if (currentInfo != null) {
|
if (binding != null) {
|
||||||
updateOverlayData(currentInfo.getName(),
|
setOverlayPlayPauseImage(false);
|
||||||
currentInfo.getUploaderName(),
|
if (currentInfo != null) {
|
||||||
currentInfo.getThumbnails());
|
updateOverlayData(currentInfo.getName(),
|
||||||
|
currentInfo.getUploaderName(),
|
||||||
|
currentInfo.getThumbnails());
|
||||||
|
}
|
||||||
|
updateOverlayPlayQueueButtonVisibility();
|
||||||
}
|
}
|
||||||
updateOverlayPlayQueueButtonVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -7,3 +7,16 @@ import androidx.core.os.BundleCompat
|
|||||||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Bundle?.toDebugString(): String {
|
||||||
|
if (this == null) {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
val string = StringBuilder("Bundle{")
|
||||||
|
for (key in this.keySet()) {
|
||||||
|
@Suppress("DEPRECATION") // we want this[key] to return items of any type
|
||||||
|
string.append(" ").append(key).append(" => ").append(this[key]).append(";")
|
||||||
|
}
|
||||||
|
string.append(" }")
|
||||||
|
return string.toString()
|
||||||
|
}
|
||||||
|
@ -26,6 +26,10 @@ public class RemotePlaylistManager {
|
|||||||
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
|
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
|
||||||
|
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
|
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
|
||||||
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
|
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
|
||||||
.subscribeOn(Schedulers.io());
|
.subscribeOn(Schedulers.io());
|
||||||
|
@ -183,7 +183,10 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
private void bind() {
|
private void bind() {
|
||||||
|
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||||
|
// it will be rewritten when NewPlayer will replace the current player.
|
||||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||||
|
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
unbindService(serviceConnection);
|
unbindService(serviceConnection);
|
||||||
@ -221,7 +224,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
Log.d(TAG, "Player service is connected");
|
Log.d(TAG, "Player service is connected");
|
||||||
|
|
||||||
if (service instanceof PlayerService.LocalBinder) {
|
if (service instanceof PlayerService.LocalBinder) {
|
||||||
player = ((PlayerService.LocalBinder) service).getPlayer();
|
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||||
|
@ -55,6 +55,7 @@ import android.content.SharedPreferences;
|
|||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
|
|
||||||
@ -71,6 +72,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
|||||||
import com.google.android.exoplayer2.Player.PositionInfo;
|
import com.google.android.exoplayer2.Player.PositionInfo;
|
||||||
import com.google.android.exoplayer2.Timeline;
|
import com.google.android.exoplayer2.Timeline;
|
||||||
import com.google.android.exoplayer2.Tracks;
|
import com.google.android.exoplayer2.Tracks;
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||||
import com.google.android.exoplayer2.source.MediaSource;
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
import com.google.android.exoplayer2.text.CueGroup;
|
import com.google.android.exoplayer2.text.CueGroup;
|
||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||||
@ -269,7 +271,16 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
//region Constructor
|
//region Constructor
|
||||||
|
|
||||||
public Player(@NonNull final PlayerService service) {
|
/**
|
||||||
|
* @param service the service this player resides in
|
||||||
|
* @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and
|
||||||
|
* could possibly be reused with multiple player instances
|
||||||
|
* @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service
|
||||||
|
* and could possibly be reused with multiple player instances
|
||||||
|
*/
|
||||||
|
public Player(@NonNull final PlayerService service,
|
||||||
|
@NonNull final MediaSessionCompat mediaSession,
|
||||||
|
@NonNull final MediaSessionConnector sessionConnector) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
context = service;
|
context = service;
|
||||||
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
@ -302,7 +313,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
// notification ui in the UIs list, since the notification depends on the media session in
|
// notification ui in the UIs list, since the notification depends on the media session in
|
||||||
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
|
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
|
||||||
UIs = new PlayerUiList(
|
UIs = new PlayerUiList(
|
||||||
new MediaSessionPlayerUi(this),
|
new MediaSessionPlayerUi(this, mediaSession, sessionConnector),
|
||||||
new NotificationPlayerUi(this)
|
new NotificationPlayerUi(this)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -646,7 +657,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
Log.d(TAG, "onPlaybackShutdown() called");
|
Log.d(TAG, "onPlaybackShutdown() called");
|
||||||
}
|
}
|
||||||
// destroys the service, which in turn will destroy the player
|
// destroys the service, which in turn will destroy the player
|
||||||
service.stopService();
|
service.destroyPlayerAndStopService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void smoothStopForImmediateReusing() {
|
public void smoothStopForImmediateReusing() {
|
||||||
@ -718,7 +729,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
pause();
|
pause();
|
||||||
break;
|
break;
|
||||||
case ACTION_CLOSE:
|
case ACTION_CLOSE:
|
||||||
service.stopService();
|
service.destroyPlayerAndStopService();
|
||||||
break;
|
break;
|
||||||
case ACTION_PLAY_PAUSE:
|
case ACTION_PLAY_PAUSE:
|
||||||
playPause();
|
playPause();
|
||||||
@ -1375,6 +1386,19 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
public void onCues(@NonNull final CueGroup cueGroup) {
|
public void onCues(@NonNull final CueGroup cueGroup) {
|
||||||
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
|
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector}
|
||||||
|
* receives an {@code onPrepare()} call. This function allows restoring the default behavior
|
||||||
|
* that would happen if there was no playback preparer set, i.e. to just call
|
||||||
|
* {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the
|
||||||
|
* {@link MediaSessionConnector} file.
|
||||||
|
*/
|
||||||
|
public void onPrepare() {
|
||||||
|
if (!exoPlayerIsNull()) {
|
||||||
|
simpleExoPlayer.prepare();
|
||||||
|
}
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,75 +21,142 @@ package org.schabi.newpipe.player;
|
|||||||
|
|
||||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||||
|
|
||||||
import android.app.Service;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Binder;
|
import android.os.Binder;
|
||||||
|
import android.os.Bundle;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.support.v4.media.MediaBrowserCompat;
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.app.ServiceCompat;
|
||||||
|
import androidx.media.MediaBrowserServiceCompat;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.ktx.BundleKt;
|
||||||
|
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||||
|
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One service for all players.
|
* One service for all players.
|
||||||
*/
|
*/
|
||||||
public final class PlayerService extends Service {
|
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||||
private static final String TAG = PlayerService.class.getSimpleName();
|
private static final String TAG = PlayerService.class.getSimpleName();
|
||||||
private static final boolean DEBUG = Player.DEBUG;
|
private static final boolean DEBUG = Player.DEBUG;
|
||||||
|
|
||||||
|
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||||
|
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||||
|
|
||||||
|
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||||
|
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||||
|
// only used in conjunction with the media browser.
|
||||||
|
private MediaBrowserImpl mediaBrowserImpl;
|
||||||
|
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||||
|
|
||||||
|
// these are instantiated in onCreate() as per
|
||||||
|
// https://developer.android.com/training/cars/media#browser_workflow
|
||||||
|
private MediaSessionCompat mediaSession;
|
||||||
|
private MediaSessionConnector sessionConnector;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
private Player player;
|
private Player player;
|
||||||
|
|
||||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||||
|
* stopped.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Service's LifeCycle
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
|
//region Service lifecycle
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "onCreate() called");
|
Log.d(TAG, "onCreate() called");
|
||||||
}
|
}
|
||||||
assureCorrectAppLanguage(this);
|
assureCorrectAppLanguage(this);
|
||||||
ThemeHelper.setTheme(this);
|
ThemeHelper.setTheme(this);
|
||||||
|
|
||||||
player = new Player(this);
|
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||||
/*
|
|
||||||
Create the player notification and start immediately the service in foreground,
|
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||||
otherwise if nothing is played or initializing the player and its components (especially
|
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||||
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
|
setSessionToken(mediaSession.getSessionToken());
|
||||||
service would never be put in the foreground while we said to the system we would do so
|
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||||
*/
|
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||||
player.UIs().get(NotificationPlayerUi.class)
|
|
||||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||||
|
this,
|
||||||
|
sessionConnector::setCustomErrorMessage,
|
||||||
|
() -> sessionConnector.setCustomErrorMessage(null),
|
||||||
|
(playWhenReady) -> {
|
||||||
|
if (player != null) {
|
||||||
|
player.onPrepare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||||
|
|
||||||
|
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||||
|
// but be aware that the Android system might start the service just to perform media
|
||||||
|
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||||
|
// startForeground means creating a useless empty notification. In case it's really needed
|
||||||
|
// the player instance can be created here, but startForeground() should definitely not be
|
||||||
|
// called here unless the service is actually starting in the foreground, to avoid the
|
||||||
|
// useless notification.
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||||
|
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||||
Be sure that the player notification is set and the service is started in foreground,
|
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||||
otherwise, the app may crash on Android 8+ as the service would never be put in the
|
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||||
foreground while we said to the system we would do so
|
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||||
The service is always requested to be started in foreground, so always creating a
|
final boolean playerWasNull = (player == null);
|
||||||
notification if there is no one already and starting the service in foreground should
|
if (playerWasNull) {
|
||||||
not create any issues
|
// make sure the player exists, in case the service was resumed
|
||||||
If the service is already started in foreground, requesting it to be started shouldn't
|
player = new Player(this, mediaSession, sessionConnector);
|
||||||
do anything
|
}
|
||||||
*/
|
|
||||||
if (player != null) {
|
// Be sure that the player notification is set and the service is started in foreground,
|
||||||
|
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||||
|
// foreground while we said to the system we would do so. The service is always
|
||||||
|
// requested to be started in foreground, so always creating a notification if there is
|
||||||
|
// no one already and starting the service in foreground should not create any issues.
|
||||||
|
// If the service is already started in foreground, requesting it to be started
|
||||||
|
// shouldn't do anything.
|
||||||
player.UIs().get(NotificationPlayerUi.class)
|
player.UIs().get(NotificationPlayerUi.class)
|
||||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||||
|
|
||||||
|
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||||
|
// notify that a new player was created (but do it after creating the foreground
|
||||||
|
// notification just to make sure we don't incur, due to slowness, in
|
||||||
|
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||||
|
onPlayerStartedOrStopped.accept(player);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||||
@ -100,7 +167,7 @@ public final class PlayerService extends Service {
|
|||||||
Stop the service in this case, which will be removed from the foreground and its
|
Stop the service in this case, which will be removed from the foreground and its
|
||||||
notification cancelled in its destruction
|
notification cancelled in its destruction
|
||||||
*/
|
*/
|
||||||
stopSelf();
|
destroyPlayerAndStopService();
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,29 +209,84 @@ public final class PlayerService extends Service {
|
|||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "destroy() called");
|
Log.d(TAG, "destroy() called");
|
||||||
}
|
}
|
||||||
|
super.onDestroy();
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
|
mediaBrowserPlaybackPreparer.dispose();
|
||||||
|
mediaSession.release();
|
||||||
|
mediaBrowserImpl.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanup() {
|
private void cleanup() {
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
|
if (onPlayerStartedOrStopped != null) {
|
||||||
|
// notify that the player is being destroyed
|
||||||
|
onPlayerStartedOrStopped.accept(null);
|
||||||
|
}
|
||||||
player.destroy();
|
player.destroy();
|
||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||||
|
mediaSession.setActive(false);
|
||||||
|
|
||||||
|
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||||
|
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopService() {
|
/**
|
||||||
|
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||||
|
* session to inactive. Stops the foreground service and removes the player notification
|
||||||
|
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||||
|
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||||
|
* system accesses the media browser even when no player is running).
|
||||||
|
*/
|
||||||
|
public void destroyPlayerAndStopService() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||||
|
}
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
stopSelf();
|
|
||||||
|
// This only really stops the service if there are no other service connections (see docs):
|
||||||
|
// for example the (Android Auto) media browser binder will block stopService().
|
||||||
|
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||||
|
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||||
|
// other service connections), but this would be a waste of resources since the service
|
||||||
|
// would be immediately restarted by those same connections to perform the queries.
|
||||||
|
stopService(new Intent(this, PlayerService.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void attachBaseContext(final Context base) {
|
protected void attachBaseContext(final Context base) {
|
||||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||||
}
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Bind
|
||||||
@Override
|
@Override
|
||||||
public IBinder onBind(final Intent intent) {
|
public IBinder onBind(final Intent intent) {
|
||||||
return mBinder;
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||||
|
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||||
|
// Note that this binder might be reused multiple times while the service is alive, even
|
||||||
|
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||||
|
return mBinder;
|
||||||
|
|
||||||
|
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||||
|
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||||
|
// browser service, pass the onBind to the superclass.
|
||||||
|
return super.onBind(intent);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class LocalBinder extends Binder {
|
public static class LocalBinder extends Binder {
|
||||||
@ -177,9 +299,52 @@ public final class PlayerService extends Service {
|
|||||||
public PlayerService getService() {
|
public PlayerService getService() {
|
||||||
return playerService.get();
|
return playerService.get();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Player getPlayer() {
|
/**
|
||||||
return playerService.get().player;
|
* @return the current active player instance. May be null, since the player service can outlive
|
||||||
|
* the player e.g. to respond to Android Auto media browser queries.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public Player getPlayer() {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener that will be called when the player is started or stopped. If a
|
||||||
|
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||||
|
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||||
|
* @param listener the listener to set or unset
|
||||||
|
*/
|
||||||
|
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||||
|
this.onPlayerStartedOrStopped = listener;
|
||||||
|
if (listener != null) {
|
||||||
|
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||||
|
listener.accept(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Media browser
|
||||||
|
@Override
|
||||||
|
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||||
|
final int clientUid,
|
||||||
|
@Nullable final Bundle rootHints) {
|
||||||
|
// TODO check if the accessing package has permission to view data
|
||||||
|
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLoadChildren(@NonNull final String parentId,
|
||||||
|
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||||
|
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSearch(@NonNull final String query,
|
||||||
|
final Bundle extras,
|
||||||
|
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||||
|
mediaBrowserImpl.onSearch(query, result);
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,48 @@
|
|||||||
package org.schabi.newpipe.player.event;
|
package org.schabi.newpipe.player.event;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.schabi.newpipe.player.PlayerService;
|
import org.schabi.newpipe.player.PlayerService;
|
||||||
import org.schabi.newpipe.player.Player;
|
import org.schabi.newpipe.player.Player;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player
|
||||||
|
* connections and disconnections. "Connected" here means that the service (resp. the
|
||||||
|
* player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}.
|
||||||
|
* "Disconnected" means that either the service (resp. the player) was stopped completely, or that
|
||||||
|
* {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound.
|
||||||
|
*/
|
||||||
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
|
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
|
||||||
void onServiceConnected(Player player,
|
/**
|
||||||
PlayerService playerService,
|
* The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||||
boolean playAfterConnect);
|
* but the player may not be active at this moment, e.g. in case the service is running to
|
||||||
|
* respond to Android Auto media browser queries without playing anything.
|
||||||
|
* {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there
|
||||||
|
* is a player.
|
||||||
|
*
|
||||||
|
* @param playerService the newly connected player service
|
||||||
|
*/
|
||||||
|
void onServiceConnected(@NonNull PlayerService playerService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player service is already connected and the player was just started.
|
||||||
|
*
|
||||||
|
* @param player the newly connected or started player
|
||||||
|
* @param playAfterConnect whether to open the video player in the video details fragment
|
||||||
|
*/
|
||||||
|
void onPlayerConnected(@NonNull Player player, boolean playAfterConnect);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player got disconnected, for one of these reasons: the player is getting closed while
|
||||||
|
* leaving the service open for future media browser queries, the service is stopping
|
||||||
|
* completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding.
|
||||||
|
*/
|
||||||
|
void onPlayerDisconnected();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||||
|
* either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because
|
||||||
|
* the service is stopping completely.
|
||||||
|
*/
|
||||||
void onServiceDisconnected();
|
void onServiceDisconnected();
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,10 @@ import org.schabi.newpipe.player.PlayerType;
|
|||||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class PlayerHolder {
|
public final class PlayerHolder {
|
||||||
|
|
||||||
@ -44,7 +48,16 @@ public final class PlayerHolder {
|
|||||||
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||||
private boolean bound;
|
private boolean bound;
|
||||||
@Nullable private PlayerService playerService;
|
@Nullable private PlayerService playerService;
|
||||||
@Nullable private Player player;
|
|
||||||
|
private Optional<Player> getPlayer() {
|
||||||
|
return Optional.ofNullable(playerService)
|
||||||
|
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<PlayQueue> getPlayQueue() {
|
||||||
|
// player play queue might be null e.g. while player is starting
|
||||||
|
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
||||||
@ -54,21 +67,15 @@ public final class PlayerHolder {
|
|||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
public PlayerType getType() {
|
public PlayerType getType() {
|
||||||
if (player == null) {
|
return getPlayer().map(Player::getPlayerType).orElse(null);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return player.getPlayerType();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isPlaying() {
|
public boolean isPlaying() {
|
||||||
if (player == null) {
|
return getPlayer().map(Player::isPlaying).orElse(false);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return player.isPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isPlayerOpen() {
|
public boolean isPlayerOpen() {
|
||||||
return player != null;
|
return getPlayer().isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -77,7 +84,7 @@ public final class PlayerHolder {
|
|||||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||||
*/
|
*/
|
||||||
public boolean isPlayQueueReady() {
|
public boolean isPlayQueueReady() {
|
||||||
return player != null && player.getPlayQueue() != null;
|
return getPlayQueue().isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isBound() {
|
public boolean isBound() {
|
||||||
@ -85,18 +92,11 @@ public final class PlayerHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int getQueueSize() {
|
public int getQueueSize() {
|
||||||
if (player == null || player.getPlayQueue() == null) {
|
return getPlayQueue().map(PlayQueue::size).orElse(0);
|
||||||
// player play queue might be null e.g. while player is starting
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return player.getPlayQueue().size();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getQueuePosition() {
|
public int getQueuePosition() {
|
||||||
if (player == null || player.getPlayQueue() == null) {
|
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return player.getPlayQueue().getIndex();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||||
@ -107,9 +107,10 @@ public final class PlayerHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force reload data from service
|
// Force reload data from service
|
||||||
if (player != null) {
|
if (playerService != null) {
|
||||||
listener.onServiceConnected(player, playerService, false);
|
listener.onServiceConnected(playerService);
|
||||||
startPlayerListener();
|
startPlayerListener();
|
||||||
|
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +122,9 @@ public final class PlayerHolder {
|
|||||||
|
|
||||||
public void startService(final boolean playAfterConnect,
|
public void startService(final boolean playAfterConnect,
|
||||||
final PlayerServiceExtendedEventListener newListener) {
|
final PlayerServiceExtendedEventListener newListener) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||||
|
}
|
||||||
final Context context = getCommonContext();
|
final Context context = getCommonContext();
|
||||||
setListener(newListener);
|
setListener(newListener);
|
||||||
if (bound) {
|
if (bound) {
|
||||||
@ -130,14 +134,24 @@ public final class PlayerHolder {
|
|||||||
// and NullPointerExceptions inside the service because the service will be
|
// and NullPointerExceptions inside the service because the service will be
|
||||||
// bound twice. Prevent it with unbinding first
|
// bound twice. Prevent it with unbinding first
|
||||||
unbind(context);
|
unbind(context);
|
||||||
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
|
final Intent intent = new Intent(context, PlayerService.class);
|
||||||
|
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||||
|
ContextCompat.startForegroundService(context, intent);
|
||||||
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
||||||
bind(context);
|
bind(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopService() {
|
public void stopService() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "stopService() called");
|
||||||
|
}
|
||||||
|
if (playerService != null) {
|
||||||
|
playerService.destroyPlayerAndStopService();
|
||||||
|
}
|
||||||
final Context context = getCommonContext();
|
final Context context = getCommonContext();
|
||||||
unbind(context);
|
unbind(context);
|
||||||
|
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||||
|
// to make sure to stop the service even if playerService is null by any chance.
|
||||||
context.stopService(new Intent(context, PlayerService.class));
|
context.stopService(new Intent(context, PlayerService.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,11 +181,16 @@ public final class PlayerHolder {
|
|||||||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||||
|
|
||||||
playerService = localBinder.getService();
|
playerService = localBinder.getService();
|
||||||
player = localBinder.getPlayer();
|
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onServiceConnected(player, playerService, playAfterConnect);
|
listener.onServiceConnected(playerService);
|
||||||
|
getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
|
||||||
}
|
}
|
||||||
startPlayerListener();
|
startPlayerListener();
|
||||||
|
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||||
|
|
||||||
|
// notify the main activity that binding the service has completed, so that it can
|
||||||
|
// open the bottom mini-player
|
||||||
|
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,15 +198,28 @@ public final class PlayerHolder {
|
|||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "bind() called");
|
Log.d(TAG, "bind() called");
|
||||||
}
|
}
|
||||||
|
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||||
bound = context.bindService(serviceIntent, serviceConnection,
|
|
||||||
Context.BIND_AUTO_CREATE);
|
|
||||||
if (!bound) {
|
if (!bound) {
|
||||||
context.unbindService(serviceConnection);
|
context.unbindService(serviceConnection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void tryBindIfNeeded(final Context context) {
|
||||||
|
if (!bound) {
|
||||||
|
// flags=0 means the service will not be started if it does not already exist. In this
|
||||||
|
// case the return value is not useful, as a value of "true" does not really indicate
|
||||||
|
// that the service is going to be bound.
|
||||||
|
bind(context, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean bind(final Context context, final int flags) {
|
||||||
|
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||||
|
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||||
|
return context.bindService(serviceIntent, serviceConnection, flags);
|
||||||
|
}
|
||||||
|
|
||||||
private void unbind(final Context context) {
|
private void unbind(final Context context) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "unbind() called");
|
Log.d(TAG, "unbind() called");
|
||||||
@ -198,25 +230,32 @@ public final class PlayerHolder {
|
|||||||
bound = false;
|
bound = false;
|
||||||
stopPlayerListener();
|
stopPlayerListener();
|
||||||
playerService = null;
|
playerService = null;
|
||||||
player = null;
|
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
|
listener.onPlayerDisconnected();
|
||||||
listener.onServiceDisconnected();
|
listener.onServiceDisconnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startPlayerListener() {
|
private void startPlayerListener() {
|
||||||
if (player != null) {
|
if (playerService != null) {
|
||||||
player.setFragmentListener(internalListener);
|
// setting the player listener will take care of calling relevant callbacks if the
|
||||||
|
// player in the service is (not) already active, also see playerStateListener below
|
||||||
|
playerService.setPlayerListener(playerStateListener);
|
||||||
}
|
}
|
||||||
|
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopPlayerListener() {
|
private void stopPlayerListener() {
|
||||||
if (player != null) {
|
if (playerService != null) {
|
||||||
player.removeFragmentListener(internalListener);
|
playerService.setPlayerListener(null);
|
||||||
}
|
}
|
||||||
|
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This listener will be held by the players created by {@link PlayerService}.
|
||||||
|
*/
|
||||||
private final PlayerServiceEventListener internalListener =
|
private final PlayerServiceEventListener internalListener =
|
||||||
new PlayerServiceEventListener() {
|
new PlayerServiceEventListener() {
|
||||||
@Override
|
@Override
|
||||||
@ -303,4 +342,23 @@ public final class PlayerHolder {
|
|||||||
unbind(getCommonContext());
|
unbind(getCommonContext());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||||
|
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||||
|
* Auto media browser queries.
|
||||||
|
*/
|
||||||
|
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||||
|
if (listener != null) {
|
||||||
|
if (player == null) {
|
||||||
|
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||||
|
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||||
|
// before setting its player to null
|
||||||
|
listener.onPlayerDisconnected();
|
||||||
|
} else {
|
||||||
|
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||||
|
player.setFragmentListener(internalListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package org.schabi.newpipe.player.mediabrowser
|
||||||
|
|
||||||
|
import org.schabi.newpipe.BuildConfig
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
|
|
||||||
|
internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
|
||||||
|
internal const val ID_ROOT = "//$ID_AUTHORITY"
|
||||||
|
internal const val ID_BOOKMARKS = "playlists"
|
||||||
|
internal const val ID_HISTORY = "history"
|
||||||
|
internal const val ID_INFO_ITEM = "item"
|
||||||
|
|
||||||
|
internal const val ID_LOCAL = "local"
|
||||||
|
internal const val ID_REMOTE = "remote"
|
||||||
|
internal const val ID_URL = "url"
|
||||||
|
internal const val ID_STREAM = "stream"
|
||||||
|
internal const val ID_PLAYLIST = "playlist"
|
||||||
|
internal const val ID_CHANNEL = "channel"
|
||||||
|
|
||||||
|
internal fun infoItemTypeToString(type: InfoType): String {
|
||||||
|
return when (type) {
|
||||||
|
InfoType.STREAM -> ID_STREAM
|
||||||
|
InfoType.PLAYLIST -> ID_PLAYLIST
|
||||||
|
InfoType.CHANNEL -> ID_CHANNEL
|
||||||
|
else -> throw IllegalStateException("Unexpected value: $type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun infoItemTypeFromString(type: String): InfoType {
|
||||||
|
return when (type) {
|
||||||
|
ID_STREAM -> InfoType.STREAM
|
||||||
|
ID_PLAYLIST -> InfoType.PLAYLIST
|
||||||
|
ID_CHANNEL -> InfoType.CHANNEL
|
||||||
|
else -> throw IllegalStateException("Unexpected value: $type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseError(mediaId: String): ContentNotAvailableException {
|
||||||
|
return ContentNotAvailableException("Failed to parse media ID $mediaId")
|
||||||
|
}
|
@ -0,0 +1,399 @@
|
|||||||
|
package org.schabi.newpipe.player.mediabrowser
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
|
import androidx.media.MediaBrowserServiceCompat.Result
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||||
|
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||||
|
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||||
|
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||||
|
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
|
||||||
|
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||||
|
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
|
import org.schabi.newpipe.util.image.ImageStrategy
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to cleanly separate the Service implementation (in
|
||||||
|
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
|
||||||
|
*
|
||||||
|
* @param notifyChildrenChanged takes the parent id of the children that changed
|
||||||
|
*/
|
||||||
|
class MediaBrowserImpl(
|
||||||
|
private val context: Context,
|
||||||
|
notifyChildrenChanged: Consumer<String>, // parentId
|
||||||
|
) {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private var disposables = CompositeDisposable()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||||
|
disposables.add(
|
||||||
|
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//region Cleanup
|
||||||
|
fun dispose() {
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region onGetRoot
|
||||||
|
fun onGetRoot(
|
||||||
|
clientPackageName: String,
|
||||||
|
clientUid: Int,
|
||||||
|
rootHints: Bundle?
|
||||||
|
): MediaBrowserServiceCompat.BrowserRoot {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putBoolean(
|
||||||
|
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||||
|
)
|
||||||
|
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region onLoadChildren
|
||||||
|
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onLoadChildren($parentId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
result.detach() // allows sendResult() to happen later
|
||||||
|
disposables.add(
|
||||||
|
onLoadChildren(parentId)
|
||||||
|
.subscribe(
|
||||||
|
{ result.sendResult(it) },
|
||||||
|
{ throwable ->
|
||||||
|
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||||
|
result.sendResult(null)
|
||||||
|
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
try {
|
||||||
|
val parentIdUri = Uri.parse(parentId)
|
||||||
|
val path = ArrayList(parentIdUri.pathSegments)
|
||||||
|
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
return Single.just(
|
||||||
|
listOf(
|
||||||
|
createRootMediaItem(
|
||||||
|
ID_BOOKMARKS,
|
||||||
|
context.resources.getString(R.string.tab_bookmarks_short),
|
||||||
|
R.drawable.ic_bookmark_white
|
||||||
|
),
|
||||||
|
createRootMediaItem(
|
||||||
|
ID_HISTORY,
|
||||||
|
context.resources.getString(R.string.action_history),
|
||||||
|
R.drawable.ic_history_white
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (/*val uriType = */path.removeAt(0)) {
|
||||||
|
ID_BOOKMARKS -> {
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
return populateBookmarks()
|
||||||
|
}
|
||||||
|
if (path.size == 2) {
|
||||||
|
val localOrRemote = path[0]
|
||||||
|
val playlistId = path[1].toLong()
|
||||||
|
if (localOrRemote == ID_LOCAL) {
|
||||||
|
return populateLocalPlaylist(playlistId)
|
||||||
|
} else if (localOrRemote == ID_REMOTE) {
|
||||||
|
return populateRemotePlaylist(playlistId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Unknown playlist URI: $parentId")
|
||||||
|
throw parseError(parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
ID_HISTORY -> return populateHistory()
|
||||||
|
|
||||||
|
else -> throw parseError(parentId)
|
||||||
|
}
|
||||||
|
} catch (e: ContentNotAvailableException) {
|
||||||
|
return Single.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRootMediaItem(
|
||||||
|
mediaId: String?,
|
||||||
|
folderName: String?,
|
||||||
|
@DrawableRes iconResId: Int
|
||||||
|
): MediaBrowserCompat.MediaItem {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder.setMediaId(mediaId)
|
||||||
|
builder.setTitle(folderName)
|
||||||
|
val resources = context.resources
|
||||||
|
builder.setIconUri(
|
||||||
|
Uri.Builder()
|
||||||
|
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||||
|
.authority(resources.getResourcePackageName(iconResId))
|
||||||
|
.appendPath(resources.getResourceTypeName(iconResId))
|
||||||
|
.appendPath(resources.getResourceEntryName(iconResId))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putString(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||||
|
context.getString(R.string.app_name)
|
||||||
|
)
|
||||||
|
builder.setExtras(extras)
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder
|
||||||
|
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||||
|
.setTitle(playlist.orderingName)
|
||||||
|
.setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putString(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||||
|
context.resources.getString(R.string.tab_bookmarks),
|
||||||
|
)
|
||||||
|
builder.setExtras(extras)
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||||
|
.setTitle(item.name)
|
||||||
|
|
||||||
|
when (item.infoType) {
|
||||||
|
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||||
|
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
|
||||||
|
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||||
|
builder.setIconUri(Uri.parse(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMediaId(): Uri.Builder {
|
||||||
|
return Uri.Builder().authority(ID_AUTHORITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
|
||||||
|
return buildMediaId()
|
||||||
|
.appendPath(ID_BOOKMARKS)
|
||||||
|
.appendPath(playlistType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
|
||||||
|
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
|
||||||
|
.appendPath(playlistId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
|
||||||
|
return buildMediaId()
|
||||||
|
.appendPath(ID_INFO_ITEM)
|
||||||
|
.appendPath(infoItemTypeToString(item.infoType))
|
||||||
|
.appendPath(item.serviceId.toString())
|
||||||
|
.appendQueryParameter(ID_URL, item.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
|
||||||
|
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||||
|
.build().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createLocalPlaylistStreamMediaItem(
|
||||||
|
playlistId: Long,
|
||||||
|
item: PlaylistStreamEntry,
|
||||||
|
index: Int,
|
||||||
|
): MediaBrowserCompat.MediaItem {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||||
|
.setTitle(item.streamEntity.title)
|
||||||
|
.setSubtitle(item.streamEntity.uploader)
|
||||||
|
.setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
|
||||||
|
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRemotePlaylistStreamMediaItem(
|
||||||
|
playlistId: Long,
|
||||||
|
item: StreamInfoItem,
|
||||||
|
index: Int,
|
||||||
|
): MediaBrowserCompat.MediaItem {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||||
|
.setTitle(item.name)
|
||||||
|
.setSubtitle(item.uploaderName)
|
||||||
|
|
||||||
|
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||||
|
builder.setIconUri(Uri.parse(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMediaIdForPlaylistIndex(
|
||||||
|
isRemote: Boolean,
|
||||||
|
playlistId: Long,
|
||||||
|
index: Int,
|
||||||
|
): String {
|
||||||
|
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||||
|
.appendPath(index.toString())
|
||||||
|
.build().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMediaIdForInfoItem(item: InfoItem): String {
|
||||||
|
return buildInfoItemMediaId(item).build().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||||
|
return history.map { items ->
|
||||||
|
items.map { this.createHistoryMediaItem(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||||
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
val mediaId = buildMediaId()
|
||||||
|
.appendPath(ID_HISTORY)
|
||||||
|
.appendPath(streamHistoryEntry.streamId.toString())
|
||||||
|
.build().toString()
|
||||||
|
builder.setMediaId(mediaId)
|
||||||
|
.setTitle(streamHistoryEntry.streamEntity.title)
|
||||||
|
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
|
||||||
|
.setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl))
|
||||||
|
|
||||||
|
return MediaBrowserCompat.MediaItem(
|
||||||
|
builder.build(),
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
|
||||||
|
return MergedPlaylistManager.getMergedOrderedPlaylists(
|
||||||
|
LocalPlaylistManager(database),
|
||||||
|
RemotePlaylistManager(database)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
val playlists = getMergedPlaylists().firstOrError()
|
||||||
|
return playlists.map { playlist ->
|
||||||
|
playlist.map { this.createPlaylistMediaItem(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||||
|
return playlist.map { items ->
|
||||||
|
items.mapIndexed { index, item ->
|
||||||
|
createLocalPlaylistStreamMediaItem(playlistId, item, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||||
|
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||||
|
.map {
|
||||||
|
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||||
|
// be no way to show the error properly in Android Auto anyway
|
||||||
|
it.relatedItems.mapIndexed { index, item ->
|
||||||
|
createRemotePlaylistStreamMediaItem(playlistId, item, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Search
|
||||||
|
fun onSearch(
|
||||||
|
query: String,
|
||||||
|
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||||
|
) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onSearch($query)")
|
||||||
|
}
|
||||||
|
|
||||||
|
result.detach() // allows sendResult() to happen later
|
||||||
|
disposables.add(
|
||||||
|
searchMusicBySongTitle(query)
|
||||||
|
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||||
|
// be no way to show the error properly in Android Auto anyway
|
||||||
|
.map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(
|
||||||
|
{ result.sendResult(it) },
|
||||||
|
{ throwable ->
|
||||||
|
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||||
|
result.sendResult(null)
|
||||||
|
Log.e(TAG, "Search error for query=\"$query\": $throwable")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
|
||||||
|
val serviceId = ServiceHelper.getSelectedServiceId(context)
|
||||||
|
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,259 @@
|
|||||||
|
package org.schabi.newpipe.player.mediabrowser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.ResultReceiver
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.android.exoplayer2.Player
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.MainActivity
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
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.local.playlist.LocalPlaylistManager
|
||||||
|
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||||
|
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueue
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
|
||||||
|
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 java.util.function.BiConsumer
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to cleanly separate the Service implementation (in
|
||||||
|
* [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this
|
||||||
|
* file). We currently use the playback preparer only in conjunction with the media browser: the
|
||||||
|
* playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start
|
||||||
|
* playback of the corresponding streams or playlists.
|
||||||
|
*
|
||||||
|
* @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat],
|
||||||
|
* calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)`
|
||||||
|
* @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)`
|
||||||
|
* @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because
|
||||||
|
* `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of
|
||||||
|
* `player.prepare()` if the playback preparer is not null, but we want the original behavior
|
||||||
|
*/
|
||||||
|
class MediaBrowserPlaybackPreparer(
|
||||||
|
private val context: Context,
|
||||||
|
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
|
||||||
|
private val clearMediaSessionError: Runnable,
|
||||||
|
private val onPrepare: Consumer<Boolean>,
|
||||||
|
) : PlaybackPreparer {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private var disposable: Disposable? = null
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
disposable?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
//region Overrides
|
||||||
|
override fun getSupportedPrepareActions(): Long {
|
||||||
|
return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepare(playWhenReady: Boolean) {
|
||||||
|
onPrepare.accept(playWhenReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
if (MainActivity.DEBUG) {
|
||||||
|
Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)")
|
||||||
|
}
|
||||||
|
|
||||||
|
disposable?.dispose()
|
||||||
|
disposable = extractPlayQueueFromMediaId(mediaId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ playQueue ->
|
||||||
|
clearMediaSessionError.run()
|
||||||
|
NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady)
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
|
||||||
|
onPrepareError()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
onUnsupportedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
onUnsupportedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCommand(
|
||||||
|
player: Player,
|
||||||
|
command: String,
|
||||||
|
extras: Bundle?,
|
||||||
|
cb: ResultReceiver?
|
||||||
|
): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Errors
|
||||||
|
private fun onUnsupportedError() {
|
||||||
|
setMediaSessionError.accept(
|
||||||
|
context.getString(R.string.content_not_supported),
|
||||||
|
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPrepareError() {
|
||||||
|
setMediaSessionError.accept(
|
||||||
|
context.getString(R.string.error_snackbar_message),
|
||||||
|
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Building play queues from playlists and history
|
||||||
|
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||||
|
return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||||
|
.map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||||
|
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||||
|
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||||
|
// ignore info.errors, i.e. ignore errors about specific items, since there would
|
||||||
|
// be no way to show the error properly in Android Auto anyway
|
||||||
|
.map { info -> PlaylistPlayQueue(info, index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPlayQueueFromMediaId(mediaId: String): Single<PlayQueue> {
|
||||||
|
try {
|
||||||
|
val mediaIdUri = Uri.parse(mediaId)
|
||||||
|
val path = ArrayList(mediaIdUri.pathSegments)
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (/*val uriType = */path.removeAt(0)) {
|
||||||
|
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
|
||||||
|
mediaId,
|
||||||
|
path,
|
||||||
|
mediaIdUri.getQueryParameter(ID_URL)
|
||||||
|
)
|
||||||
|
|
||||||
|
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
|
||||||
|
|
||||||
|
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
|
||||||
|
mediaId,
|
||||||
|
path,
|
||||||
|
mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
} catch (e: ContentNotAvailableException) {
|
||||||
|
return Single.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ContentNotAvailableException::class)
|
||||||
|
private fun extractPlayQueueFromPlaylistMediaId(
|
||||||
|
mediaId: String,
|
||||||
|
path: MutableList<String>,
|
||||||
|
url: String?,
|
||||||
|
): Single<PlayQueue> {
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val playlistType = path.removeAt(0)) {
|
||||||
|
ID_LOCAL, ID_REMOTE -> {
|
||||||
|
if (path.size != 2) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
val playlistId = path[0].toLong()
|
||||||
|
val index = path[1].toInt()
|
||||||
|
return if (playlistType == ID_LOCAL)
|
||||||
|
extractLocalPlayQueue(playlistId, index)
|
||||||
|
else
|
||||||
|
extractRemotePlayQueue(playlistId, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
ID_URL -> {
|
||||||
|
if (path.size != 1 || url == null) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serviceId = path[0].toInt()
|
||||||
|
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||||
|
.map { PlaylistPlayQueue(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ContentNotAvailableException::class)
|
||||||
|
private fun extractPlayQueueFromHistoryMediaId(
|
||||||
|
mediaId: String,
|
||||||
|
path: List<String>,
|
||||||
|
): Single<PlayQueue> {
|
||||||
|
if (path.size != 1) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val streamId = path[0].toLong()
|
||||||
|
return database.streamHistoryDAO().getHistory()
|
||||||
|
.firstOrError()
|
||||||
|
.map { items ->
|
||||||
|
val infoItems = items
|
||||||
|
.filter { it.streamId == streamId }
|
||||||
|
.map { it.toStreamInfoItem() }
|
||||||
|
SinglePlayQueue(infoItems, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ContentNotAvailableException::class)
|
||||||
|
private fun extractPlayQueueFromInfoItemMediaId(
|
||||||
|
mediaId: String,
|
||||||
|
path: List<String>,
|
||||||
|
url: String,
|
||||||
|
): Single<PlayQueue> {
|
||||||
|
if (path.size != 2) {
|
||||||
|
throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serviceId = path[1].toInt()
|
||||||
|
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
|
||||||
|
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||||
|
.map { SinglePlayQueue(it) }
|
||||||
|
|
||||||
|
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||||
|
.map { PlaylistPlayQueue(it) }
|
||||||
|
|
||||||
|
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
|
||||||
|
.map { info ->
|
||||||
|
val playableTab = info.tabs
|
||||||
|
.firstOrNull { ChannelTabHelper.isStreamsTab(it) }
|
||||||
|
?: throw ContentNotAvailableException("No streams tab found")
|
||||||
|
return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw parseError(mediaId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
|
||||||
|
}
|
||||||
|
}
|
@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private static final String TAG = "MediaSessUi";
|
private static final String TAG = "MediaSessUi";
|
||||||
|
|
||||||
@Nullable
|
@NonNull
|
||||||
private MediaSessionCompat mediaSession;
|
private final MediaSessionCompat mediaSession;
|
||||||
@Nullable
|
@NonNull
|
||||||
private MediaSessionConnector sessionConnector;
|
private final MediaSessionConnector sessionConnector;
|
||||||
|
|
||||||
private final String ignoreHardwareMediaButtonsKey;
|
private final String ignoreHardwareMediaButtonsKey;
|
||||||
private boolean shouldIgnoreHardwareMediaButtons = false;
|
private boolean shouldIgnoreHardwareMediaButtons = false;
|
||||||
@ -50,9 +50,13 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
private List<NotificationActionData> prevNotificationActions = List.of();
|
private List<NotificationActionData> prevNotificationActions = List.of();
|
||||||
|
|
||||||
|
|
||||||
public MediaSessionPlayerUi(@NonNull final Player player) {
|
public MediaSessionPlayerUi(@NonNull final Player player,
|
||||||
|
@NonNull final MediaSessionCompat mediaSession,
|
||||||
|
@NonNull final MediaSessionConnector sessionConnector) {
|
||||||
super(player);
|
super(player);
|
||||||
ignoreHardwareMediaButtonsKey =
|
this.mediaSession = mediaSession;
|
||||||
|
this.sessionConnector = sessionConnector;
|
||||||
|
this.ignoreHardwareMediaButtonsKey =
|
||||||
context.getString(R.string.ignore_hardware_media_buttons_key);
|
context.getString(R.string.ignore_hardware_media_buttons_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,10 +65,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
super.initPlayer();
|
super.initPlayer();
|
||||||
destroyPlayer(); // release previously used resources
|
destroyPlayer(); // release previously used resources
|
||||||
|
|
||||||
mediaSession = new MediaSessionCompat(context, TAG);
|
|
||||||
mediaSession.setActive(true);
|
mediaSession.setActive(true);
|
||||||
|
|
||||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
|
||||||
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
|
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
|
||||||
sessionConnector.setPlayer(getForwardingPlayer());
|
sessionConnector.setPlayer(getForwardingPlayer());
|
||||||
|
|
||||||
@ -89,27 +91,18 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
public void destroyPlayer() {
|
public void destroyPlayer() {
|
||||||
super.destroyPlayer();
|
super.destroyPlayer();
|
||||||
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
|
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
|
||||||
if (sessionConnector != null) {
|
sessionConnector.setMediaButtonEventHandler(null);
|
||||||
sessionConnector.setMediaButtonEventHandler(null);
|
sessionConnector.setPlayer(null);
|
||||||
sessionConnector.setPlayer(null);
|
sessionConnector.setQueueNavigator(null);
|
||||||
sessionConnector.setQueueNavigator(null);
|
mediaSession.setActive(false);
|
||||||
sessionConnector = null;
|
|
||||||
}
|
|
||||||
if (mediaSession != null) {
|
|
||||||
mediaSession.setActive(false);
|
|
||||||
mediaSession.release();
|
|
||||||
mediaSession = null;
|
|
||||||
}
|
|
||||||
prevNotificationActions = List.of();
|
prevNotificationActions = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
||||||
super.onThumbnailLoaded(bitmap);
|
super.onThumbnailLoaded(bitmap);
|
||||||
if (sessionConnector != null) {
|
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
||||||
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
sessionConnector.invalidateMediaSessionMetadata();
|
||||||
sessionConnector.invalidateMediaSessionMetadata();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -200,8 +193,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionConnector == null) {
|
if (!mediaSession.isActive()) {
|
||||||
// sessionConnector will be null after destroyPlayer is called
|
// mediaSession will be inactive after destroyPlayer is called
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,13 +28,17 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
|
|||||||
private transient Disposable fetchReactor;
|
private transient Disposable fetchReactor;
|
||||||
|
|
||||||
protected AbstractInfoPlayQueue(final T info) {
|
protected AbstractInfoPlayQueue(final T info) {
|
||||||
|
this(info, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected AbstractInfoPlayQueue(final T info, final int index) {
|
||||||
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
|
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
|
||||||
info.getRelatedItems()
|
info.getRelatedItems()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(StreamInfoItem.class::isInstance)
|
.filter(StreamInfoItem.class::isInstance)
|
||||||
.map(StreamInfoItem.class::cast)
|
.map(StreamInfoItem.class::cast)
|
||||||
.collect(Collectors.toList()),
|
.collect(Collectors.toList()),
|
||||||
0);
|
index);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected AbstractInfoPlayQueue(final int serviceId,
|
protected AbstractInfoPlayQueue(final int serviceId,
|
||||||
|
@ -16,6 +16,10 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue<PlaylistInfo>
|
|||||||
super(info);
|
super(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PlaylistPlayQueue(final PlaylistInfo info, final int index) {
|
||||||
|
super(info, index);
|
||||||
|
}
|
||||||
|
|
||||||
public PlaylistPlayQueue(final int serviceId,
|
public PlaylistPlayQueue(final int serviceId,
|
||||||
final String url,
|
final String url,
|
||||||
final Page nextPage,
|
final Page nextPage,
|
||||||
|
@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
|||||||
private void end() {
|
private void end() {
|
||||||
windowManager.removeView(closeOverlayBinding.getRoot());
|
windowManager.removeView(closeOverlayBinding.getRoot());
|
||||||
closeOverlayBinding = null;
|
closeOverlayBinding = null;
|
||||||
player.getService().stopService();
|
player.getService().destroyPlayerAndStopService();
|
||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
@ -96,6 +96,7 @@ public final class NavigationHelper {
|
|||||||
}
|
}
|
||||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
|
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
|
||||||
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||||
|
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||||
|
|
||||||
return intent;
|
return intent;
|
||||||
}
|
}
|
||||||
|
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="@color/white"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
|
||||||
|
</vector>
|
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="@color/white"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
|
||||||
|
</vector>
|
@ -33,6 +33,7 @@
|
|||||||
<string name="show_info">Show info</string>
|
<string name="show_info">Show info</string>
|
||||||
<string name="tab_subscriptions">Subscriptions</string>
|
<string name="tab_subscriptions">Subscriptions</string>
|
||||||
<string name="tab_bookmarks">Bookmarked Playlists</string>
|
<string name="tab_bookmarks">Bookmarked Playlists</string>
|
||||||
|
<string name="tab_bookmarks_short">Playlists</string>
|
||||||
<string name="tab_choose">Choose Tab</string>
|
<string name="tab_choose">Choose Tab</string>
|
||||||
<string name="controls_background_title">Background</string>
|
<string name="controls_background_title">Background</string>
|
||||||
<string name="controls_popup_title">Popup</string>
|
<string name="controls_popup_title">Popup</string>
|
||||||
|
3
app/src/main/res/xml/automotive_app_desc.xml
Normal file
3
app/src/main/res/xml/automotive_app_desc.xml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<automotiveApp>
|
||||||
|
<uses name="media" />
|
||||||
|
</automotiveApp>
|
Loading…
x
Reference in New Issue
Block a user