diff --git a/.gitmodules b/.gitmodules index fc9d8f809..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "app/src/main/java/org/schabi/newpipe/extractor"] - path = app/src/main/java/org/schabi/newpipe/extractor - url = https://github.com/TeamNewPipe/NewPipeExtractor.git diff --git a/README.md b/README.md index add5aed9a..374c56d00 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ NewPipe does not use any Google framework libraries, or the YouTube API. It only * Subscribe to channels * Search history * Search/Watch Playlists +* Watch as queues Playlists +* Queuing videos ### Coming Features * Multiservice support (eg. SoundCloud) * Bookmarks -* Watch as queues Playlists -* Queuing videos * Subtitles support * livestream support * ... and many more diff --git a/app/build.gradle b/app/build.gradle index 092d2ed26..da23f6acd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,7 +48,7 @@ dependencies { exclude module: 'support-annotations' } - compile 'com.github.TeamNewPipe:NewPipeExtractor:7899cd1' + compile 'com.github.TeamNewPipe:NewPipeExtractor:b9d0941' testCompile 'junit:junit:4.12' testCompile 'org.mockito:mockito-core:1.10.19' @@ -66,7 +66,7 @@ dependencies { compile 'de.hdodenhof:circleimageview:2.1.0' compile 'com.github.nirhart:parallaxscroll:1.0' compile 'com.nononsenseapps:filepicker:3.0.1' - compile 'com.google.android.exoplayer:exoplayer:r2.5.1' + compile 'com.google.android.exoplayer:exoplayer:r2.5.4' debugCompile 'com.facebook.stetho:stetho:1.5.0' debugCompile 'com.facebook.stetho:stetho-urlconnection:1.5.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ae994de7..4f97a7201 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,16 @@ android:name=".player.BackgroundPlayer" android:exported="false"/> + + + + diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 03b856d31..056db3500 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -27,6 +27,7 @@ import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; @@ -309,7 +310,7 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { } @Override - public void onVideoPlayed(StreamInfo streamInfo, VideoStream videoStream) { + public void onVideoPlayed(StreamInfo streamInfo, @Nullable VideoStream videoStream) { addWatchHistoryEntry(streamInfo); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 4bb0c2cca..63b7e9ace 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.fragments.detail; import android.app.Activity; +import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -28,6 +29,7 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; @@ -60,9 +62,12 @@ import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.history.HistoryListener; import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.MainVideoPlayer; import org.schabi.newpipe.player.PopupVideoPlayer; import org.schabi.newpipe.player.old.PlayVideoActivity; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.Constants; @@ -89,12 +94,11 @@ import io.reactivex.schedulers.Schedulers; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public class VideoDetailFragment extends BaseStateFragment implements BackPressable, SharedPreferences.OnSharedPreferenceChangeListener, View.OnClickListener { +public class VideoDetailFragment extends BaseStateFragment implements BackPressable, SharedPreferences.OnSharedPreferenceChangeListener, View.OnClickListener, View.OnLongClickListener { public static final String AUTO_PLAY = "auto_play"; // Amount of videos to show on start private static final int INITIAL_RELATED_VIDEOS = 8; - private static final String KORE_PACKET = "org.xbmc.kore"; private ActionBarHandler actionBarHandler; private ArrayList sortedStreamVideosList; @@ -141,6 +145,7 @@ public class VideoDetailFragment extends BaseStateFragment implement private TextView detailControlsBackground; private TextView detailControlsPopup; + private TextView appendControlsDetail; private LinearLayout videoDescriptionRootLayout; private TextView videoUploadDateView; @@ -317,10 +322,10 @@ public class VideoDetailFragment extends BaseStateFragment implement switch (v.getId()) { case R.id.detail_controls_background: - openBackgroundPlayer(); + openBackgroundPlayer(false); break; case R.id.detail_controls_popup: - openPopupPlayer(); + openPopupPlayer(false); break; case R.id.detail_uploader_root_layout: if (currentInfo.uploader_url == null || currentInfo.uploader_url.isEmpty()) { @@ -341,6 +346,22 @@ public class VideoDetailFragment extends BaseStateFragment implement } } + @Override + public boolean onLongClick(View v) { + if (isLoading.get() || currentInfo == null) return false; + + switch (v.getId()) { + case R.id.detail_controls_background: + openBackgroundPlayer(true); + break; + case R.id.detail_controls_popup: + openPopupPlayer(true); + break; + } + + return true; + } + private void toggleTitleAndDescription() { if (videoDescriptionRootLayout.getVisibility() == View.VISIBLE) { videoTitleTextView.setMaxLines(1); @@ -400,6 +421,7 @@ public class VideoDetailFragment extends BaseStateFragment implement detailControlsBackground = rootView.findViewById(R.id.detail_controls_background); detailControlsPopup = rootView.findViewById(R.id.detail_controls_popup); + appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view); @@ -445,6 +467,32 @@ public class VideoDetailFragment extends BaseStateFragment implement detailControlsBackground.setOnClickListener(this); detailControlsPopup.setOnClickListener(this); relatedStreamExpandButton.setOnClickListener(this); + + detailControlsBackground.setLongClickable(true); + detailControlsPopup.setLongClickable(true); + detailControlsBackground.setOnLongClickListener(this); + detailControlsPopup.setOnLongClickListener(this); + detailControlsBackground.setOnTouchListener(getOnControlsTouchListener()); + detailControlsPopup.setOnTouchListener(getOnControlsTouchListener()); + } + + private View.OnTouchListener getOnControlsTouchListener() { + return new View.OnTouchListener() { + @Override + public boolean onTouch(View view, MotionEvent motionEvent) { + if (!PreferenceManager.getDefaultSharedPreferences(activity).getBoolean(getString(R.string.show_hold_to_append_key), true)) return false; + + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { + animateView(appendControlsDetail, true, 250, 0, new Runnable() { + @Override + public void run() { + animateView(appendControlsDetail, false, 1500, 1000); + } + }); + } + return false; + } + }; } private void initThumbnailViews(StreamInfo info) { @@ -513,6 +561,24 @@ public class VideoDetailFragment extends BaseStateFragment implement return (!isLoading.get() && actionBarHandler.onItemSelected(item)) || super.onOptionsItemSelected(item); } + private static void showInstallKoreDialog(final Context context) { + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage(R.string.kore_not_found) + .setPositiveButton(R.string.install, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + NavigationHelper.installKore(context); + } + }) + .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }); + builder.create().show(); + } + private void setupActionBarHandler(final StreamInfo info) { if (DEBUG) Log.d(TAG, "setupActionBarHandler() called with: info = [" + info + "]"); sortedStreamVideosList = new ArrayList<>(ListHelper.getSortedStreamVideosList(activity, info.video_streams, info.video_only_streams, false)); @@ -542,30 +608,13 @@ public class VideoDetailFragment extends BaseStateFragment implement @Override public void onActionSelected(int selectedStreamId) { try { - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setPackage(KORE_PACKET); - intent.setData(Uri.parse(info.url.replace("https", "http"))); - activity.startActivity(intent); + NavigationHelper.playWithKore(activity, Uri.parse(info.url.replace("https", "http"))); + if(activity instanceof HistoryListener) { + ((HistoryListener) activity).onVideoPlayed(info, null); + } } catch (Exception e) { - e.printStackTrace(); - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setMessage(R.string.kore_not_found) - .setPositiveButton(R.string.install, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse(activity.getString(R.string.fdroid_kore_url))); - activity.startActivity(intent); - } - }) - .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - - } - }); - builder.create().show(); + if(DEBUG) Log.i(TAG, "Failed to start kore", e); + showInstallKoreDialog(activity); } } }); @@ -713,7 +762,7 @@ public class VideoDetailFragment extends BaseStateFragment implement // Play Utils //////////////////////////////////////////////////////////////////////////*/ - private void openBackgroundPlayer() { + private void openBackgroundPlayer(final boolean append) { AudioStream audioStream = currentInfo.audio_streams.get(ListHelper.getDefaultAudioFormat(activity, currentInfo.audio_streams)); if (activity instanceof HistoryListener) { @@ -724,13 +773,13 @@ public class VideoDetailFragment extends BaseStateFragment implement .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); if (!useExternalAudioPlayer && android.os.Build.VERSION.SDK_INT >= 16) { - openNormalBackgroundPlayer(audioStream); + openNormalBackgroundPlayer(append); } else { openExternalBackgroundPlayer(audioStream); } } - private void openPopupPlayer() { + private void openPopupPlayer(final boolean append) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !PermissionHelper.checkSystemAlertWindowPermission(activity)) { Toast toast = Toast.makeText(activity, R.string.msg_popup_permission, Toast.LENGTH_LONG); TextView messageView = toast.getView().findViewById(android.R.id.message); @@ -743,9 +792,16 @@ public class VideoDetailFragment extends BaseStateFragment implement ((HistoryListener) activity).onVideoPlayed(currentInfo, getSelectedVideoStream()); } - Toast.makeText(activity, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - Intent mIntent = NavigationHelper.getOpenVideoPlayerIntent(activity, PopupVideoPlayer.class, currentInfo, actionBarHandler.getSelectedVideoStream()); - activity.startService(mIntent); + final PlayQueue playQueue = new SinglePlayQueue(currentInfo); + final Intent intent; + if (append) { + Toast.makeText(activity, R.string.popup_playing_append, Toast.LENGTH_SHORT).show(); + intent = NavigationHelper.getPlayerEnqueueIntent(activity, PopupVideoPlayer.class, playQueue); + } else { + Toast.makeText(activity, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); + intent = NavigationHelper.getPlayerIntent(activity, PopupVideoPlayer.class, playQueue, getSelectedVideoStream().resolution); + } + activity.startService(intent); } private void openVideoPlayer() { @@ -763,9 +819,15 @@ public class VideoDetailFragment extends BaseStateFragment implement } - private void openNormalBackgroundPlayer(AudioStream audioStream) { - activity.startService(NavigationHelper.getOpenBackgroundPlayerIntent(activity, currentInfo, audioStream)); - Toast.makeText(activity, R.string.background_player_playing_toast, Toast.LENGTH_SHORT).show(); + private void openNormalBackgroundPlayer(final boolean append) { + final PlayQueue playQueue = new SinglePlayQueue(currentInfo); + if (append) { + activity.startService(NavigationHelper.getPlayerEnqueueIntent(activity, BackgroundPlayer.class, playQueue)); + Toast.makeText(activity, R.string.background_player_append, Toast.LENGTH_SHORT).show(); + } else { + activity.startService(NavigationHelper.getPlayerIntent(activity, BackgroundPlayer.class, playQueue)); + Toast.makeText(activity, R.string.background_player_playing_toast, Toast.LENGTH_SHORT).show(); + } } private void openExternalBackgroundPlayer(AudioStream audioStream) { @@ -808,7 +870,8 @@ public class VideoDetailFragment extends BaseStateFragment implement || (Build.VERSION.SDK_INT < 16); if (!useOldPlayer) { // ExoPlayer - mIntent = NavigationHelper.getOpenVideoPlayerIntent(activity, MainVideoPlayer.class, currentInfo, actionBarHandler.getSelectedVideoStream()); + final PlayQueue playQueue = new SinglePlayQueue(currentInfo); + mIntent = NavigationHelper.getPlayerIntent(activity, MainVideoPlayer.class, playQueue, getSelectedVideoStream().resolution); } else { // Internal Player mIntent = new Intent(activity, PlayVideoActivity.class) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 48661969f..35f6a08d3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -187,7 +187,11 @@ public abstract class BaseListFragment extends BaseStateFragment implem ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(true); - supportActionBar.setDisplayHomeAsUpEnabled(true); + if(useAsFrontPage) { + supportActionBar.setDisplayHomeAsUpEnabled(false); + } else { + supportActionBar.setDisplayHomeAsUpEnabled(true); + } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index eeb95f9b1..b88d54524 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -1,17 +1,22 @@ package org.schabi.newpipe.fragments.list.playlist; +import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ListExtractor; @@ -19,9 +24,15 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.player.BackgroundPlayer; +import org.schabi.newpipe.player.MainVideoPlayer; +import org.schabi.newpipe.player.PopupVideoPlayer; +import org.schabi.newpipe.playlist.ExternalPlayQueue; +import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.PermissionHelper; import io.reactivex.Single; @@ -40,6 +51,10 @@ public class PlaylistFragment extends BaseListInfoFragment { private ImageView headerUploaderAvatar; private TextView headerStreamCount; + private Button headerPlayAllButton; + private Button headerPopupButton; + private Button headerBackgroundButton; + public static PlaylistFragment getInstance(int serviceId, String url, String name) { PlaylistFragment instance = new PlaylistFragment(); instance.setInitialData(serviceId, url, name); @@ -67,6 +82,10 @@ public class PlaylistFragment extends BaseListInfoFragment { headerUploaderAvatar = headerRootLayout.findViewById(R.id.uploader_avatar_view); headerStreamCount = headerRootLayout.findViewById(R.id.playlist_stream_count); + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_play_bg_button); + return headerRootLayout; } @@ -132,11 +151,48 @@ public class PlaylistFragment extends BaseListInfoFragment { } imageLoader.displayImage(result.uploader_avatar_url, headerUploaderAvatar, DISPLAY_AVATAR_OPTIONS); - headerStreamCount.setText(result.stream_count + " videos"); + headerStreamCount.setText(getResources().getQuantityString(R.plurals.videos, (int) result.stream_count, (int) result.stream_count)); if (!result.errors.isEmpty()) { showSnackBarError(result.errors, UserAction.REQUESTED_PLAYLIST, NewPipe.getNameOfService(result.service_id), result.url, 0); } + + headerPlayAllButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + startActivity(buildPlaylistIntent(MainVideoPlayer.class)); + } + }); + headerPopupButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !PermissionHelper.checkSystemAlertWindowPermission(activity)) { + Toast toast = Toast.makeText(activity, R.string.msg_popup_permission, Toast.LENGTH_LONG); + TextView messageView = toast.getView().findViewById(android.R.id.message); + if (messageView != null) messageView.setGravity(Gravity.CENTER); + toast.show(); + return; + } + activity.startService(buildPlaylistIntent(PopupVideoPlayer.class)); + } + }); + headerBackgroundButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + activity.startService(buildPlaylistIntent(BackgroundPlayer.class)); + } + }); + } + + private Intent buildPlaylistIntent(final Class targetClazz) { + final PlayQueue playQueue = new ExternalPlayQueue( + currentInfo.service_id, + currentInfo.url, + currentInfo.next_streams_url, + infoListAdapter.getItemsList(), + 0 + ); + return NavigationHelper.getPlayerIntent(activity, targetClazz, playQueue); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 90d4d9741..fae97bb7b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -113,6 +113,7 @@ public class SearchFragment extends BaseListFragment suggestionPublisher = PublishSubject.create(); private Disposable searchDisposable; @@ -160,7 +161,12 @@ public class SearchFragment extends BaseListFragment items = new ArrayList<>(); private final Context context; private OnSuggestionItemSelected listener; + private boolean showSugestinHistory = true; public interface OnSuggestionItemSelected { void onSuggestionItemSelected(SuggestionItem item); @@ -31,7 +32,16 @@ public class SuggestionListAdapter extends RecyclerView.Adapter items) { this.items.clear(); - this.items.addAll(items); + if (showSugestinHistory) { + this.items.addAll(items); + } else { + // remove history items if history is disabled + for (SuggestionItem item : items) { + if (!item.fromHistory) { + this.items.add(item); + } + } + } notifyDataSetChanged(); } @@ -39,6 +49,10 @@ public class SuggestionListAdapter extends RecyclerView.Adapter extends BaseFragme private RecyclerView mRecyclerView; private HistoryEntryAdapter mHistoryAdapter; private ItemTouchHelper.SimpleCallback mHistoryItemSwipeCallback; - private int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + // private int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; private HistoryDAO mHistoryDataSource; private PublishSubject> mHistoryEntryDeleteSubject; @@ -99,7 +99,11 @@ public abstract class HistoryFragment extends BaseFragme } }); - mHistoryItemSwipeCallback = new ItemTouchHelper.SimpleCallback(0, allowedSwipeToDeleteDirections) { + + } + + protected void historyItemSwipeCallback(int swipeDirection) { + mHistoryItemSwipeCallback = new ItemTouchHelper.SimpleCallback(0, swipeDirection) { @Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { return false; @@ -241,6 +245,7 @@ public abstract class HistoryFragment extends BaseFragme if (mHistoryIsEnabled) { mRecyclerView.setVisibility(View.VISIBLE); } else { + mRecyclerView.setVisibility(View.GONE); mDisabledView.setVisibility(View.VISIBLE); } @@ -264,10 +269,6 @@ public abstract class HistoryFragment extends BaseFragme mRecyclerViewState = mRecyclerView.getLayoutManager().onSaveInstanceState(); } - public void setAllowedSwipeToDeleteDirections(int allowedSwipeToDeleteDirections) { - this.allowedSwipeToDeleteDirections = allowedSwipeToDeleteDirections; - } - /** * Called when history enabled flag is changed. * diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryListener.java b/app/src/main/java/org/schabi/newpipe/history/HistoryListener.java index 8b6c91328..5c729b022 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryListener.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryListener.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.history; +import android.support.annotation.Nullable; + import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -9,9 +11,10 @@ public interface HistoryListener { * Called when a video is played * * @param streamInfo the stream info - * @param videoStream the video stream that is played + * @param videoStream the video stream that is played. Can be null if it's not sure what + * quality was viewed (e.g. with Kodi). */ - void onVideoPlayed(StreamInfo streamInfo, VideoStream videoStream); + void onVideoPlayed(StreamInfo streamInfo, @Nullable VideoStream videoStream); /** * Called when the audio is played in the background diff --git a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java index 888086a83..91e2cecff 100644 --- a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java @@ -1,9 +1,12 @@ package org.schabi.newpipe.history; import android.content.Context; +import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -17,11 +20,19 @@ import org.schabi.newpipe.util.NavigationHelper; public class SearchHistoryFragment extends HistoryFragment { + private static int allowedSwipeToDeleteDirections = ItemTouchHelper.RIGHT; + @NonNull public static SearchHistoryFragment newInstance() { return new SearchHistoryFragment(); } + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + historyItemSwipeCallback(allowedSwipeToDeleteDirections); + } + @NonNull @Override protected SearchHistoryAdapter createAdapter() { diff --git a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java index 086528af7..d898bf353 100644 --- a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java @@ -7,6 +7,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -26,6 +27,8 @@ import org.schabi.newpipe.util.NavigationHelper; public class WatchedHistoryFragment extends HistoryFragment { + private static int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT; + @NonNull public static WatchedHistoryFragment newInstance() { return new WatchedHistoryFragment(); @@ -34,7 +37,7 @@ public class WatchedHistoryFragment extends HistoryFragment { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - + historyItemSwipeCallback(allowedSwipeToDeleteDirections); } @StringRes diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java index be9247569..a7a1e2797 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -26,25 +26,31 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.graphics.Bitmap; -import android.net.wifi.WifiManager; import android.os.Build; import android.os.IBinder; -import android.os.PowerManager; import android.support.annotation.IntRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.util.Log; import android.widget.RemoteViews; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.source.MediaSource; + import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.event.PlayerEventListener; +import org.schabi.newpipe.player.helper.LockManager; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ThemeHelper; -import java.io.Serializable; +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; /** @@ -52,38 +58,39 @@ import java.io.Serializable; * * @author mauriciocolli */ -public class BackgroundPlayer extends Service { +public final class BackgroundPlayer extends Service { private static final String TAG = "BackgroundPlayer"; private static final boolean DEBUG = BasePlayer.DEBUG; public static final String ACTION_CLOSE = "org.schabi.newpipe.player.BackgroundPlayer.CLOSE"; public static final String ACTION_PLAY_PAUSE = "org.schabi.newpipe.player.BackgroundPlayer.PLAY_PAUSE"; - public static final String ACTION_OPEN_DETAIL = "org.schabi.newpipe.player.BackgroundPlayer.OPEN_DETAIL"; + public static final String ACTION_OPEN_CONTROLS = "org.schabi.newpipe.player.BackgroundPlayer.OPEN_CONTROLS"; public static final String ACTION_REPEAT = "org.schabi.newpipe.player.BackgroundPlayer.REPEAT"; - public static final String ACTION_FAST_REWIND = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_FAST_REWIND"; - public static final String ACTION_FAST_FORWARD = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_FAST_FORWARD"; - - public static final String AUDIO_STREAM = "video_only_audio_stream"; - private AudioStream audioStream; + public static final String ACTION_PLAY_NEXT = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_PLAY_NEXT"; + public static final String ACTION_PLAY_PREVIOUS = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_PLAY_PREVIOUS"; private BasePlayerImpl basePlayerImpl; - private PowerManager powerManager; - private WifiManager wifiManager; + private LockManager lockManager; + /*////////////////////////////////////////////////////////////////////////// + // Service-Activity Binder + //////////////////////////////////////////////////////////////////////////*/ - private PowerManager.WakeLock wakeLock; - private WifiManager.WifiLock wifiLock; + private PlayerEventListener activityListener; + private IBinder mBinder; /*////////////////////////////////////////////////////////////////////////// // Notification //////////////////////////////////////////////////////////////////////////*/ - private static final int NOTIFICATION_ID = 123789; + private static final int NOTIFICATION_ID = 123789; private NotificationManager notificationManager; private NotificationCompat.Builder notBuilder; private RemoteViews notRemoteView; private RemoteViews bigNotRemoteView; private final String setAlphaMethodName = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) ? "setImageAlpha" : "setAlpha"; + private boolean shouldUpdateOnProgress; + /*////////////////////////////////////////////////////////////////////////// // Service's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -92,12 +99,14 @@ public class BackgroundPlayer extends Service { public void onCreate() { if (DEBUG) Log.d(TAG, "onCreate() called"); notificationManager = ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)); - powerManager = ((PowerManager) getSystemService(POWER_SERVICE)); - wifiManager = ((WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE)); + lockManager = new LockManager(this); ThemeHelper.setTheme(this); basePlayerImpl = new BasePlayerImpl(this); basePlayerImpl.setup(); + + mBinder = new PlayerServiceBinder(basePlayerImpl); + shouldUpdateOnProgress = true; } @Override @@ -110,51 +119,60 @@ public class BackgroundPlayer extends Service { @Override public void onDestroy() { if (DEBUG) Log.d(TAG, "destroy() called"); - releaseWifiAndCpu(); - stopForeground(true); - if (basePlayerImpl != null) basePlayerImpl.destroy(); + onClose(); } @Override public IBinder onBind(Intent intent) { - return null; + return mBinder; } /*////////////////////////////////////////////////////////////////////////// // Actions //////////////////////////////////////////////////////////////////////////*/ - public void onOpenDetail(Context context, String videoUrl, String videoTitle) { - if (DEBUG) Log.d(TAG, "onOpenDetail() called with: context = [" + context + "], videoUrl = [" + videoUrl + "]"); - Intent i = new Intent(context, MainActivity.class); - i.putExtra(Constants.KEY_SERVICE_ID, 0); - i.putExtra(Constants.KEY_URL, videoUrl); - i.putExtra(Constants.KEY_TITLE, videoTitle); - i.putExtra(Constants.KEY_LINK_TYPE, StreamingService.LinkType.STREAM); - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(i); + public void openControl(final Context context) { + final Intent intent = new Intent(context, BackgroundPlayerActivity.class); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); } private void onClose() { - if (basePlayerImpl != null) basePlayerImpl.destroyPlayer(); + if (DEBUG) Log.d(TAG, "onClose() called"); + + if (lockManager != null) { + lockManager.releaseWifiAndCpu(); + } + if (basePlayerImpl != null) { + basePlayerImpl.stopActivityBinding(); + basePlayerImpl.destroy(); + } + if (notificationManager != null) notificationManager.cancel(NOTIFICATION_ID); + mBinder = null; + basePlayerImpl = null; + lockManager = null; + stopForeground(true); - releaseWifiAndCpu(); stopSelf(); } private void onScreenOnOff(boolean on) { if (DEBUG) Log.d(TAG, "onScreenOnOff() called with: on = [" + on + "]"); - if (on) { - if (basePlayerImpl.isPlaying() && !basePlayerImpl.isProgressLoopRunning.get()) basePlayerImpl.startProgressLoop(); - } else basePlayerImpl.stopProgressLoop(); - + shouldUpdateOnProgress = on; + basePlayerImpl.triggerProgressUpdate(); } /*////////////////////////////////////////////////////////////////////////// // Notification //////////////////////////////////////////////////////////////////////////*/ + private void resetNotification() { + notBuilder = createNotification(); + } + private NotificationCompat.Builder createNotification() { notRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, R.layout.player_notification); bigNotRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, R.layout.player_notification_expanded); @@ -173,8 +191,6 @@ public class BackgroundPlayer extends Service { } private void setupNotification(RemoteViews remoteViews) { - //if (videoThumbnail != null) remoteViews.setImageViewBitmap(R.id.notificationCover, videoThumbnail); - ///else remoteViews.setImageViewResource(R.id.notificationCover, R.drawable.dummy_thumbnail); remoteViews.setTextViewText(R.id.notificationSongName, basePlayerImpl.getVideoTitle()); remoteViews.setTextViewText(R.id.notificationArtist, basePlayerImpl.getUploaderName()); @@ -183,26 +199,16 @@ public class BackgroundPlayer extends Service { remoteViews.setOnClickPendingIntent(R.id.notificationStop, PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_CLOSE), PendingIntent.FLAG_UPDATE_CURRENT)); remoteViews.setOnClickPendingIntent(R.id.notificationContent, - PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_OPEN_DETAIL), PendingIntent.FLAG_UPDATE_CURRENT)); + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_OPEN_CONTROLS), PendingIntent.FLAG_UPDATE_CURRENT)); remoteViews.setOnClickPendingIntent(R.id.notificationRepeat, PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_REPEAT), PendingIntent.FLAG_UPDATE_CURRENT)); remoteViews.setOnClickPendingIntent(R.id.notificationFRewind, - PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_FAST_REWIND), PendingIntent.FLAG_UPDATE_CURRENT)); + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_PLAY_PREVIOUS), PendingIntent.FLAG_UPDATE_CURRENT)); remoteViews.setOnClickPendingIntent(R.id.notificationFForward, - PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_FAST_FORWARD), PendingIntent.FLAG_UPDATE_CURRENT)); + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_PLAY_NEXT), PendingIntent.FLAG_UPDATE_CURRENT)); - switch (basePlayerImpl.getCurrentRepeatMode()) { - case REPEAT_DISABLED: - remoteViews.setInt(R.id.notificationRepeat, setAlphaMethodName, 77); - break; - case REPEAT_ONE: - remoteViews.setInt(R.id.notificationRepeat, setAlphaMethodName, 255); - break; - case REPEAT_ALL: - // Waiting :) - break; - } + setRepeatModeIcon(remoteViews, basePlayerImpl.getRepeatMode()); } /** @@ -211,8 +217,8 @@ public class BackgroundPlayer extends Service { * * @param drawableId if != -1, sets the drawable with that id on the play/pause button */ - private void updateNotification(int drawableId) { - if (DEBUG) Log.d(TAG, "updateNotification() called with: drawableId = [" + drawableId + "]"); + private synchronized void updateNotification(int drawableId) { + //if (DEBUG) Log.d(TAG, "updateNotification() called with: drawableId = [" + drawableId + "]"); if (notBuilder == null) return; if (drawableId != -1) { if (notRemoteView != null) notRemoteView.setImageViewResource(R.id.notificationPlayPause, drawableId); @@ -234,136 +240,103 @@ public class BackgroundPlayer extends Service { // Utils //////////////////////////////////////////////////////////////////////////*/ - private void lockWifiAndCpu() { - if (DEBUG) Log.d(TAG, "lockWifiAndCpu() called"); - if (wakeLock != null && wakeLock.isHeld() && wifiLock != null && wifiLock.isHeld()) return; + private void setRepeatModeIcon(final RemoteViews remoteViews, final int repeatMode) { + final String methodName = "setImageResource"; - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); - wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); - - if (wakeLock != null) wakeLock.acquire(); - if (wifiLock != null) wifiLock.acquire(); + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_all); + break; + } } - - private void releaseWifiAndCpu() { - if (DEBUG) Log.d(TAG, "releaseWifiAndCpu() called"); - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); - if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); - - wakeLock = null; - wifiLock = null; - } - ////////////////////////////////////////////////////////////////////////// - private class BasePlayerImpl extends BasePlayer { + protected class BasePlayerImpl extends BasePlayer { BasePlayerImpl(Context context) { super(context); } @Override - public void handleIntent(Intent intent) { + public void handleIntent(final Intent intent) { super.handleIntent(intent); - Serializable serializable = intent.getSerializableExtra(BackgroundPlayer.AUDIO_STREAM); - if (serializable instanceof AudioStream) audioStream = (AudioStream) serializable; - playUrl(audioStream.url, MediaFormat.getSuffixById(audioStream.format), true); + resetNotification(); if (bigNotRemoteView != null) bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false); if (notRemoteView != null) notRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false); + startForeground(NOTIFICATION_ID, notBuilder.build()); } @Override - public void initThumbnail() { + public void initThumbnail(final String url) { + resetNotification(); if (notRemoteView != null) notRemoteView.setImageViewResource(R.id.notificationCover, R.drawable.dummy_thumbnail); if (bigNotRemoteView != null) bigNotRemoteView.setImageViewResource(R.id.notificationCover, R.drawable.dummy_thumbnail); updateNotification(-1); - super.initThumbnail(); + super.initThumbnail(url); } @Override public void onThumbnailReceived(Bitmap thumbnail) { super.onThumbnailReceived(thumbnail); + if (thumbnail != null) { + // rebuild notification here since remote view does not release bitmaps, causing memory leaks + resetNotification(); + if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + updateNotification(-1); } } - @Override - public void playUrl(String url, String format, boolean autoPlay) { - super.playUrl(url, format, autoPlay); - - notBuilder = createNotification(); - startForeground(NOTIFICATION_ID, notBuilder.build()); - } - @Override public void onPrepared(boolean playWhenReady) { super.onPrepared(playWhenReady); - if (simpleExoPlayer.getDuration() < 15000) { - FAST_FORWARD_REWIND_AMOUNT = 2000; - } else if (simpleExoPlayer.getDuration() > 60 * 60 * 1000) { - FAST_FORWARD_REWIND_AMOUNT = 60000; - } else { - FAST_FORWARD_REWIND_AMOUNT = 10000; - } - PROGRESS_LOOP_INTERVAL = 1000; - basePlayerImpl.getPlayer().setVolume(1f); + simpleExoPlayer.setVolume(1f); } @Override - public void onRepeatClicked() { - super.onRepeatClicked(); - - int opacity = 255; - switch (currentRepeatMode) { - case REPEAT_DISABLED: - opacity = 77; - break; - case REPEAT_ONE: - opacity = 255; - break; - case REPEAT_ALL: - // Waiting :) - break; - } - if (notRemoteView != null) notRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, opacity); - if (bigNotRemoteView != null) bigNotRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, opacity); - updateNotification(-1); + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlayback(); } @Override public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { - if (bigNotRemoteView != null) bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, duration, currentProgress, false); - if (notRemoteView != null) notRemoteView.setProgressBar(R.id.notificationProgressBar, duration, currentProgress, false); - if (bigNotRemoteView != null) bigNotRemoteView.setTextViewText(R.id.notificationTime, getTimeString(currentProgress) + " / " + getTimeString(duration)); + updateProgress(currentProgress, duration, bufferPercent); + + if (!shouldUpdateOnProgress) return; + resetNotification(); + if (bigNotRemoteView != null) { + bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, duration, currentProgress, false); + bigNotRemoteView.setTextViewText(R.id.notificationTime, getTimeString(currentProgress) + " / " + getTimeString(duration)); + } + if (notRemoteView != null) { + notRemoteView.setProgressBar(R.id.notificationProgressBar, duration, currentProgress, false); + } updateNotification(-1); } @Override - public void onFastRewind() { - super.onFastRewind(); + public void onPlayPrevious() { + super.onPlayPrevious(); triggerProgressUpdate(); } @Override - public void onFastForward() { - super.onFastForward(); + public void onPlayNext() { + super.onPlayNext(); triggerProgressUpdate(); } - @Override - public void onLoadingChanged(boolean isLoading) { - // Disable default behavior - } - - @Override - public void onRepeatModeChanged(int i) { - - } - @Override public void destroy() { super.destroy(); @@ -371,10 +344,96 @@ public class BackgroundPlayer extends Service { if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, null); } + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Listener + //////////////////////////////////////////////////////////////////////////*/ + @Override - public void onError(Exception exception) { - exception.printStackTrace(); - stopSelf(); + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + updatePlayback(); + } + + @Override + public void onLoadingChanged(boolean isLoading) { + // Disable default behavior + } + + @Override + public void onRepeatModeChanged(int i) { + resetNotification(); + updateNotification(-1); + updatePlayback(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { + super.sync(item, info); + + resetNotification(); + updateNotification(-1); + updateMetadata(); + } + + @Override + public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + final int index = ListHelper.getDefaultAudioFormat(context, info.audio_streams); + if (index < 0) return null; + + final AudioStream audio = info.audio_streams.get(index); + return buildMediaSource(audio.url, MediaFormat.getSuffixById(audio.format)); + } + + @Override + public void shutdown() { + super.shutdown(); + onClose(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Activity Event Listener + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void setActivityListener(PlayerEventListener listener) { + activityListener = listener; + updateMetadata(); + updatePlayback(); + triggerProgressUpdate(); + } + + /*package-private*/ void removeActivityListener(PlayerEventListener listener) { + if (activityListener == listener) { + activityListener = null; + } + } + + private void updateMetadata() { + if (activityListener != null && currentInfo != null) { + activityListener.onMetadataUpdate(currentInfo); + } + } + + private void updatePlayback() { + if (activityListener != null && simpleExoPlayer != null && playQueue != null) { + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), getPlaybackParameters()); + } + } + + private void updateProgress(int currentProgress, int duration, int bufferPercent) { + if (activityListener != null) { + activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); + } + } + + private void stopActivityBinding() { + if (activityListener != null) { + activityListener.onServiceStopped(); + activityListener = null; + } } /*////////////////////////////////////////////////////////////////////////// @@ -386,10 +445,10 @@ public class BackgroundPlayer extends Service { super.setupBroadcastReceiver(intentFilter); intentFilter.addAction(ACTION_CLOSE); intentFilter.addAction(ACTION_PLAY_PAUSE); - intentFilter.addAction(ACTION_OPEN_DETAIL); + intentFilter.addAction(ACTION_OPEN_CONTROLS); intentFilter.addAction(ACTION_REPEAT); - intentFilter.addAction(ACTION_FAST_FORWARD); - intentFilter.addAction(ACTION_FAST_REWIND); + intentFilter.addAction(ACTION_PLAY_PREVIOUS); + intentFilter.addAction(ACTION_PLAY_NEXT); intentFilter.addAction(Intent.ACTION_SCREEN_ON); intentFilter.addAction(Intent.ACTION_SCREEN_OFF); @@ -400,6 +459,7 @@ public class BackgroundPlayer extends Service { @Override public void onBroadcastReceived(Intent intent) { super.onBroadcastReceived(intent); + if (intent == null || intent.getAction() == null) return; if (DEBUG) Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); switch (intent.getAction()) { case ACTION_CLOSE: @@ -408,17 +468,17 @@ public class BackgroundPlayer extends Service { case ACTION_PLAY_PAUSE: onVideoPlayPause(); break; - case ACTION_OPEN_DETAIL: - onOpenDetail(BackgroundPlayer.this, basePlayerImpl.getVideoUrl(), basePlayerImpl.getVideoTitle()); + case ACTION_OPEN_CONTROLS: + openControl(getApplicationContext()); break; case ACTION_REPEAT: onRepeatClicked(); break; - case ACTION_FAST_REWIND: - onFastRewind(); + case ACTION_PLAY_NEXT: + onPlayNext(); break; - case ACTION_FAST_FORWARD: - onFastForward(); + case ACTION_PLAY_PREVIOUS: + onPlayPrevious(); break; case Intent.ACTION_SCREEN_ON: onScreenOnOff(true); @@ -434,8 +494,14 @@ public class BackgroundPlayer extends Service { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onLoading() { - super.onLoading(); + public void changeState(int state) { + super.changeState(state); + updatePlayback(); + } + + @Override + public void onBlocked() { + super.onBlocked(); setControlsOpacity(77); updateNotification(-1); @@ -448,7 +514,7 @@ public class BackgroundPlayer extends Service { setControlsOpacity(255); updateNotification(R.drawable.ic_pause_white); - lockWifiAndCpu(); + lockManager.acquireWifiAndCpu(); } @Override @@ -456,9 +522,9 @@ public class BackgroundPlayer extends Service { super.onPaused(); updateNotification(R.drawable.ic_play_arrow_white); - if (isProgressLoopRunning.get()) stopProgressLoop(); + if (isProgressLoopRunning()) stopProgressLoop(); - releaseWifiAndCpu(); + lockManager.releaseWifiAndCpu(); } @Override @@ -466,11 +532,13 @@ public class BackgroundPlayer extends Service { super.onCompleted(); setControlsOpacity(255); + + resetNotification(); if (bigNotRemoteView != null) bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 100, false); if (notRemoteView != null) notRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 100, false); updateNotification(R.drawable.ic_replay_white); - releaseWifiAndCpu(); + lockManager.releaseWifiAndCpu(); } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayerActivity.java new file mode 100644 index 000000000..e5115f2e8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayerActivity.java @@ -0,0 +1,39 @@ +package org.schabi.newpipe.player; + +import android.content.Intent; + +import org.schabi.newpipe.R; + +public final class BackgroundPlayerActivity extends ServicePlayerActivity { + + private static final String TAG = "BackgroundPlayerActivity"; + + @Override + public String getTag() { + return TAG; + } + + @Override + public String getSupportActionTitle() { + return getResources().getString(R.string.title_activity_background_player); + } + + @Override + public Intent getBindIntent() { + return new Intent(this, BackgroundPlayer.class); + } + + @Override + public void startPlayerListener() { + if (player != null && player instanceof BackgroundPlayer.BasePlayerImpl) { + ((BackgroundPlayer.BasePlayerImpl) player).setActivityListener(this); + } + } + + @Override + public void stopPlayerListener() { + if (player != null && player instanceof BackgroundPlayer.BasePlayerImpl) { + ((BackgroundPlayer.BasePlayerImpl) player).removeActivityListener(this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index f90352fa1..8508bb237 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -19,28 +19,24 @@ package org.schabi.newpipe.player; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.SharedPreferences; import android.graphics.Bitmap; import android.media.AudioManager; import android.net.Uri; -import android.os.Handler; -import android.preference.PreferenceManager; +import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; import android.view.View; +import android.widget.Toast; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.LoadControl; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; @@ -58,90 +54,102 @@ import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.Util; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; -import org.schabi.newpipe.Downloader; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.helper.AudioReactor; +import org.schabi.newpipe.player.helper.CacheFactory; +import org.schabi.newpipe.player.helper.LoadController; +import org.schabi.newpipe.player.playback.MediaSourceManager; +import org.schabi.newpipe.player.playback.PlaybackListener; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.PlayQueueAdapter; +import org.schabi.newpipe.playlist.PlayQueueItem; -import java.io.File; -import java.text.DecimalFormat; -import java.text.NumberFormat; -import java.util.Formatter; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicBoolean; +import java.io.Serializable; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Predicate; + +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; /** * Base for the players, joining the common properties * * @author mauriciocolli */ -@SuppressWarnings({"WeakerAccess", "unused"}) -public abstract class BasePlayer implements Player.EventListener, AudioManager.OnAudioFocusChangeListener { - // TODO: Check api version for deprecated audio manager methods +@SuppressWarnings({"WeakerAccess"}) +public abstract class BasePlayer implements Player.EventListener, PlaybackListener { - public static final boolean DEBUG = false; + public static final boolean DEBUG = true; public static final String TAG = "BasePlayer"; protected Context context; - protected SharedPreferences sharedPreferences; - protected AudioManager audioManager; protected BroadcastReceiver broadcastReceiver; protected IntentFilter intentFilter; + protected PlayQueueAdapter playQueueAdapter; + /*////////////////////////////////////////////////////////////////////////// // Intent //////////////////////////////////////////////////////////////////////////*/ - public static final String VIDEO_URL = "video_url"; - public static final String VIDEO_TITLE = "video_title"; - public static final String VIDEO_THUMBNAIL_URL = "video_thumbnail_url"; - public static final String START_POSITION = "start_position"; - public static final String CHANNEL_NAME = "channel_name"; + public static final String REPEAT_MODE = "repeat_mode"; + public static final String PLAYBACK_PITCH = "playback_pitch"; public static final String PLAYBACK_SPEED = "playback_speed"; + public static final String PLAYBACK_QUALITY = "playback_quality"; + public static final String PLAY_QUEUE = "play_queue"; + public static final String APPEND_ONLY = "append_only"; - protected Bitmap videoThumbnail = null; - protected String videoUrl = ""; - protected String videoTitle = ""; - protected String videoThumbnailUrl = ""; - protected long videoStartPos = -1; - protected String uploaderName = ""; + /*////////////////////////////////////////////////////////////////////////// + // Playback + //////////////////////////////////////////////////////////////////////////*/ + + protected static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f}; + protected static final float[] PLAYBACK_PITCHES = {0.8f, 0.9f, 0.95f, 1f, 1.05f, 1.1f, 1.2f}; + + protected MediaSourceManager playbackManager; + protected PlayQueue playQueue; + + protected StreamInfo currentInfo; + protected PlayQueueItem currentItem; + + protected Toast errorToast; /*////////////////////////////////////////////////////////////////////////// // Player //////////////////////////////////////////////////////////////////////////*/ - public int FAST_FORWARD_REWIND_AMOUNT = 10000; // 10 Seconds - public static final String CACHE_FOLDER_NAME = "exoplayer"; + protected final static int FAST_FORWARD_REWIND_AMOUNT = 10000; // 10 Seconds + protected final static int PLAY_PREV_ACTIVATION_LIMIT = 5000; // 5 seconds + protected final static int PROGRESS_LOOP_INTERVAL = 500; protected SimpleExoPlayer simpleExoPlayer; + protected AudioReactor audioReactor; + protected boolean isPrepared = false; - protected MediaSource mediaSource; - protected CacheDataSourceFactory cacheDataSourceFactory; - protected final DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); - protected final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + protected DefaultTrackSelector trackSelector; + protected DataSource.Factory cacheDataSourceFactory; + protected DefaultExtractorsFactory extractorsFactory; - protected int PROGRESS_LOOP_INTERVAL = 100; - protected AtomicBoolean isProgressLoopRunning = new AtomicBoolean(); - protected Handler progressLoop; - protected Runnable progressUpdate; + protected Disposable progressUpdateReactor; //////////////////////////////////////////////////////////////////////////*/ public BasePlayer(Context context) { this.context = context; - this.progressLoop = new Handler(); - this.sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - this.audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); this.broadcastReceiver = new BroadcastReceiver() { @Override @@ -159,121 +167,121 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O initListeners(); } - private void initExoPlayerCache() { - if (cacheDataSourceFactory == null) { - DefaultDataSourceFactory dataSourceFactory = new DefaultDataSourceFactory(context, Downloader.USER_AGENT, bandwidthMeter); - File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } - - if (DEBUG) Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); - SimpleCache simpleCache = new SimpleCache(cacheDir, new LeastRecentlyUsedCacheEvictor(64 * 1024 * 1024L)); - cacheDataSourceFactory = new CacheDataSourceFactory(simpleCache, dataSourceFactory, CacheDataSource.FLAG_BLOCK_ON_CACHE, 512 * 1024); - } - } - public void initPlayer() { if (DEBUG) Log.d(TAG, "initPlayer() called with: context = [" + context + "]"); - initExoPlayerCache(); - - if (audioManager == null) { - this.audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); - } - - AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); - DefaultTrackSelector defaultTrackSelector = new DefaultTrackSelector(trackSelectionFactory); - DefaultLoadControl loadControl = new DefaultLoadControl(); + final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); + final LoadControl loadControl = new LoadController(context); final RenderersFactory renderFactory = new DefaultRenderersFactory(context); - simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, defaultTrackSelector, loadControl); + + trackSelector = new DefaultTrackSelector(trackSelectionFactory); + extractorsFactory = new DefaultExtractorsFactory(); + cacheDataSourceFactory = new CacheFactory(context); + + simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, trackSelector, loadControl); + audioReactor = new AudioReactor(context, simpleExoPlayer); + simpleExoPlayer.addListener(this); + simpleExoPlayer.setPlayWhenReady(true); } - public void initListeners() { - progressUpdate = new Runnable() { - @Override - public void run() { - //if(DEBUG) Log.d(TAG, "progressUpdate run() called"); - onUpdateProgress((int) simpleExoPlayer.getCurrentPosition(), (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); - if (isProgressLoopRunning.get()) progressLoop.postDelayed(this, PROGRESS_LOOP_INTERVAL); - } - }; + public void initListeners() {} + + private Disposable getProgressReactor() { + return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .filter(new Predicate() { + @Override + public boolean test(@NonNull Long aLong) throws Exception { + return isProgressLoopRunning(); + } + }) + .subscribe(new Consumer() { + @Override + public void accept(Long aLong) throws Exception { + triggerProgressUpdate(); + } + }); } public void handleIntent(Intent intent) { if (DEBUG) Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); if (intent == null) return; - videoUrl = intent.getStringExtra(VIDEO_URL); - videoTitle = intent.getStringExtra(VIDEO_TITLE); - videoThumbnailUrl = intent.getStringExtra(VIDEO_THUMBNAIL_URL); - videoStartPos = intent.getLongExtra(START_POSITION, -1L); - uploaderName = intent.getStringExtra(CHANNEL_NAME); - setPlaybackSpeed(intent.getFloatExtra(PLAYBACK_SPEED, getPlaybackSpeed())); + // Resolve play queue + if (!intent.hasExtra(PLAY_QUEUE)) return; + final Serializable playQueueCandidate = intent.getSerializableExtra(PLAY_QUEUE); + if (!(playQueueCandidate instanceof PlayQueue)) return; + final PlayQueue queue = (PlayQueue) playQueueCandidate; - initThumbnail(); - //play(getSelectedVideoStream(), true); + // Resolve append intents + if (intent.getBooleanExtra(APPEND_ONLY, false) && playQueue != null) { + playQueue.append(queue.getStreams()); + return; + } + + final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); + final float playbackSpeed = intent.getFloatExtra(PLAYBACK_SPEED, getPlaybackSpeed()); + final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch()); + + // Re-initialization + destroyPlayer(); + initPlayer(); + setRepeatMode(repeatMode); + setPlaybackParameters(playbackSpeed, playbackPitch); + + // Good to go... + initPlayback(queue); } - public void initThumbnail() { + protected void initPlayback(@NonNull final PlayQueue queue) { + playQueue = queue; + playQueue.init(); + playbackManager = new MediaSourceManager(this, playQueue); + + if (playQueueAdapter != null) playQueueAdapter.dispose(); + playQueueAdapter = new PlayQueueAdapter(context, playQueue); + } + + public void initThumbnail(final String url) { if (DEBUG) Log.d(TAG, "initThumbnail() called"); - videoThumbnail = null; - if (videoThumbnailUrl == null || videoThumbnailUrl.isEmpty()) return; + if (url == null || url.isEmpty()) return; ImageLoader.getInstance().resume(); - ImageLoader.getInstance().loadImage(videoThumbnailUrl, new SimpleImageLoadingListener() { + ImageLoader.getInstance().loadImage(url, new SimpleImageLoadingListener() { @Override public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { if (simpleExoPlayer == null) return; - if (DEBUG) - Log.d(TAG, "onLoadingComplete() called with: imageUri = [" + imageUri + "], view = [" + view + "], loadedImage = [" + loadedImage + "]"); - videoThumbnail = loadedImage; + if (DEBUG) Log.d(TAG, "onLoadingComplete() called with: imageUri = [" + imageUri + "], view = [" + view + "], loadedImage = [" + loadedImage + "]"); onThumbnailReceived(loadedImage); } }); } - public void playUrl(String url, String format, boolean autoPlay) { - if (DEBUG) { - Log.d(TAG, "play() called with: url = [" + url + "], autoPlay = [" + autoPlay + "]"); - } - - if (url == null || simpleExoPlayer == null) { - RuntimeException runtimeException = new RuntimeException((url == null ? "Url " : "Player ") + " null"); - onError(runtimeException); - throw runtimeException; - } - - changeState(STATE_LOADING); - - isPrepared = false; - mediaSource = buildMediaSource(url, format); - - if (simpleExoPlayer.getPlaybackState() != Player.STATE_IDLE) simpleExoPlayer.stop(); - if (videoStartPos > 0) simpleExoPlayer.seekTo(videoStartPos); - simpleExoPlayer.prepare(mediaSource); - simpleExoPlayer.setPlayWhenReady(autoPlay); + public void onThumbnailReceived(Bitmap thumbnail) { + if (DEBUG) Log.d(TAG, "onThumbnailReceived() called with: thumbnail = [" + thumbnail + "]"); } public void destroyPlayer() { if (DEBUG) Log.d(TAG, "destroyPlayer() called"); if (simpleExoPlayer != null) { + simpleExoPlayer.removeListener(this); simpleExoPlayer.stop(); simpleExoPlayer.release(); } - if (progressLoop != null && isProgressLoopRunning.get()) stopProgressLoop(); - if (audioManager != null) { - audioManager.abandonAudioFocus(this); - audioManager = null; - } + if (isProgressLoopRunning()) stopProgressLoop(); + if (playQueue != null) playQueue.dispose(); + if (playbackManager != null) playbackManager.dispose(); + if (audioReactor != null) audioReactor.abandonAudioFocus(); } public void destroy() { if (DEBUG) Log.d(TAG, "destroy() called"); destroyPlayer(); + clearThumbnailCache(); unregisterBroadcastReceiver(); - videoThumbnail = null; + + trackSelector = null; simpleExoPlayer = null; } @@ -318,6 +326,7 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O } public void onBroadcastReceived(Intent intent) { + if (intent == null || intent.getAction() == null) return; switch (intent.getAction()) { case AudioManager.ACTION_AUDIO_BECOMING_NOISY: if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); @@ -332,75 +341,25 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O } } - /*////////////////////////////////////////////////////////////////////////// - // AudioFocus - //////////////////////////////////////////////////////////////////////////*/ - - private static final int DUCK_DURATION = 1500; - private static final float DUCK_AUDIO_TO = .2f; - - @Override - public void onAudioFocusChange(int focusChange) { - if (DEBUG) Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]"); - if (simpleExoPlayer == null) return; - switch (focusChange) { - case AudioManager.AUDIOFOCUS_GAIN: - onAudioFocusGain(); - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - onAudioFocusLossCanDuck(); - break; - case AudioManager.AUDIOFOCUS_LOSS: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - onAudioFocusLoss(); - break; - } - } - - private boolean isResumeAfterAudioFocusGain() { - return sharedPreferences != null && context != null - && sharedPreferences.getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), false); - } - - protected void onAudioFocusGain() { - if (DEBUG) Log.d(TAG, "onAudioFocusGain() called"); - if (simpleExoPlayer != null) simpleExoPlayer.setVolume(DUCK_AUDIO_TO); - animateAudio(DUCK_AUDIO_TO, 1f, DUCK_DURATION); - - if (isResumeAfterAudioFocusGain()) simpleExoPlayer.setPlayWhenReady(true); - } - - protected void onAudioFocusLoss() { - if (DEBUG) Log.d(TAG, "onAudioFocusLoss() called"); - simpleExoPlayer.setPlayWhenReady(false); - } - - protected void onAudioFocusLossCanDuck() { - if (DEBUG) Log.d(TAG, "onAudioFocusLossCanDuck() called"); - // Set the volume to 1/10 on ducking - animateAudio(simpleExoPlayer.getVolume(), DUCK_AUDIO_TO, DUCK_DURATION); - } - /*////////////////////////////////////////////////////////////////////////// // States Implementation //////////////////////////////////////////////////////////////////////////*/ - public static final int STATE_LOADING = 123; + public static final int STATE_BLOCKED = 123; public static final int STATE_PLAYING = 124; public static final int STATE_BUFFERING = 125; public static final int STATE_PAUSED = 126; public static final int STATE_PAUSED_SEEK = 127; public static final int STATE_COMPLETED = 128; - protected int currentState = -1; public void changeState(int state) { if (DEBUG) Log.d(TAG, "changeState() called with: state = [" + state + "]"); currentState = state; switch (state) { - case STATE_LOADING: - onLoading(); + case STATE_BLOCKED: + onBlocked(); break; case STATE_PLAYING: onPlaying(); @@ -420,21 +379,21 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O } } - public void onLoading() { - if (DEBUG) Log.d(TAG, "onLoading() called"); - if (!isProgressLoopRunning.get()) startProgressLoop(); + public void onBlocked() { + if (DEBUG) Log.d(TAG, "onBlocked() called"); + if (!isProgressLoopRunning()) startProgressLoop(); } public void onPlaying() { if (DEBUG) Log.d(TAG, "onPlaying() called"); - if (!isProgressLoopRunning.get()) startProgressLoop(); + if (!isProgressLoopRunning()) startProgressLoop(); } public void onBuffering() { } public void onPaused() { - if (isProgressLoopRunning.get()) stopProgressLoop(); + if (isProgressLoopRunning()) stopProgressLoop(); } public void onPausedSeek() { @@ -442,60 +401,98 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O public void onCompleted() { if (DEBUG) Log.d(TAG, "onCompleted() called"); - if (isProgressLoopRunning.get()) stopProgressLoop(); - - if (currentRepeatMode == RepeatMode.REPEAT_ONE) { - changeState(STATE_LOADING); - simpleExoPlayer.seekTo(0); - } + if (playQueue.getIndex() < playQueue.size() - 1) playQueue.offsetIndex(+1); + if (isProgressLoopRunning()) stopProgressLoop(); } /*////////////////////////////////////////////////////////////////////////// - // Repeat + // Repeat and shuffle //////////////////////////////////////////////////////////////////////////*/ - protected RepeatMode currentRepeatMode = RepeatMode.REPEAT_DISABLED; - - public enum RepeatMode { - REPEAT_DISABLED, - REPEAT_ONE, - REPEAT_ALL - } - public void onRepeatClicked() { if (DEBUG) Log.d(TAG, "onRepeatClicked() called"); - // TODO: implement repeat all when playlist is implemented - // Switch the modes between DISABLED and REPEAT_ONE, till playlist is implemented - setCurrentRepeatMode(getCurrentRepeatMode() == RepeatMode.REPEAT_DISABLED ? - RepeatMode.REPEAT_ONE : - RepeatMode.REPEAT_DISABLED); + final int mode; - if (DEBUG) Log.d(TAG, "onRepeatClicked() currentRepeatMode = " + getCurrentRepeatMode().name()); + switch (getRepeatMode()) { + case Player.REPEAT_MODE_OFF: + mode = Player.REPEAT_MODE_ONE; + break; + case Player.REPEAT_MODE_ONE: + mode = Player.REPEAT_MODE_ALL; + break; + case Player.REPEAT_MODE_ALL: + default: + mode = Player.REPEAT_MODE_OFF; + break; + } + + setRepeatMode(mode); + if (DEBUG) Log.d(TAG, "onRepeatClicked() currentRepeatMode = " + getRepeatMode()); + } + + public void onShuffleClicked() { + if (DEBUG) Log.d(TAG, "onShuffleClicked() called"); + + if (playQueue == null) return; + + setRecovery(); + if (playQueue.isShuffled()) { + playQueue.unshuffle(); + } else { + playQueue.shuffle(); + } } /*////////////////////////////////////////////////////////////////////////// // ExoPlayer Listener //////////////////////////////////////////////////////////////////////////*/ + private void recover() { + final int currentSourceIndex = playQueue.getIndex(); + final PlayQueueItem currentSourceItem = playQueue.getItem(); + + // Check if already playing correct window + final boolean isCurrentWindowCorrect = simpleExoPlayer.getCurrentWindowIndex() == currentSourceIndex; + + // Check if recovering + if (isCurrentWindowCorrect && currentSourceItem != null && + currentSourceItem.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer, + * rounding this position to the nearest second will help alleviate this.*/ + final long position = currentSourceItem.getRecoveryPosition(); + + if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex + " at: " + getTimeString((int)position)); + simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition()); + playQueue.unsetRecovery(currentSourceIndex); + } + } + @Override public void onTimelineChanged(Timeline timeline, Object manifest) { + if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); + + if (playbackManager != null) { + playbackManager.load(); + } } @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + if (DEBUG) Log.d(TAG, "onTracksChanged(), track group size = " + trackGroups.length); } @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + ", pitch: " + playbackParameters.pitch); } @Override public void onLoadingChanged(boolean isLoading) { if (DEBUG) Log.d(TAG, "onLoadingChanged() called with: isLoading = [" + isLoading + "]"); - if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning.get()) stopProgressLoop(); - else if (isLoading && !isProgressLoopRunning.get()) startProgressLoop(); + if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) stopProgressLoop(); + else if (isLoading && !isProgressLoopRunning()) startProgressLoop(); } @Override @@ -503,7 +500,7 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O if (DEBUG) Log.d(TAG, "onPlayerStateChanged() called with: playWhenReady = [" + playWhenReady + "], playbackState = [" + playbackState + "]"); if (getCurrentState() == STATE_PAUSED_SEEK) { - if (DEBUG) Log.d(TAG, "onPlayerStateChanged() currently on PausedSeek"); + if (DEBUG) Log.d(TAG, "onPlayerStateChanged() is currently blocked"); return; } @@ -512,9 +509,13 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O isPrepared = false; break; case Player.STATE_BUFFERING: // 2 - if (isPrepared && getCurrentState() != STATE_LOADING) changeState(STATE_BUFFERING); + if (isPrepared) { + changeState(STATE_BUFFERING); + } break; case Player.STATE_READY: //3 + recover(); + if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); @@ -524,33 +525,172 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); break; case Player.STATE_ENDED: // 4 - changeState(STATE_COMPLETED); - isPrepared = false; + // Ensure the current window has actually ended + // since single windows that are still loading may produce an ended state + if (isCurrentWindowValid() && simpleExoPlayer.getCurrentPosition() >= simpleExoPlayer.getDuration()) { + changeState(STATE_COMPLETED); + isPrepared = false; + } + break; + } + } + + /** + * Processes the exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. + * There are multiple types of errors:

+ * + * {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}:

+ * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid, + * then we know the error is produced by transitioning into a bad window, therefore we report + * an error to the play queue based on if the current error can be skipped. + * + * This is done because ExoPlayer reports the source exceptions before window is + * transitioned on seamless playback. + * + * Because player error causes ExoPlayer to go back to {@link Player#STATE_IDLE STATE_IDLE}, + * we reset and prepare the media source again to resume playback.

+ * + * {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:

+ * If a runtime error occurred, then we can try to recover it by restarting the playback + * after setting the timestamp recovery. + * + * {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:

+ * If the renderer failed, treat the error as unrecoverable. + * + * @see Player.EventListener#onPlayerError(ExoPlaybackException) + * */ + @Override + public void onPlayerError(ExoPlaybackException error) { + if (DEBUG) Log.d(TAG, "onPlayerError() called with: error = [" + error + "]"); + if (errorToast != null) { + errorToast.cancel(); + errorToast = null; + } + + switch (error.type) { + case ExoPlaybackException.TYPE_SOURCE: + playQueue.error(isCurrentWindowValid()); + showStreamError(error); + break; + case ExoPlaybackException.TYPE_UNEXPECTED: + showRecoverableError(error); + setRecovery(); + reload(); + break; + default: + showUnrecoverableError(error); + shutdown(); break; } } @Override - public void onPlayerError(ExoPlaybackException error) { - if (DEBUG) Log.d(TAG, "onPlayerError() called with: error = [" + error + "]"); - onError(error); + public void onPositionDiscontinuity() { + // Refresh the playback if there is a transition to the next video + final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + if (DEBUG) Log.d(TAG, "onPositionDiscontinuity() called with window index = [" + newWindowIndex + "]"); + + // If the user selects a new track, then the discontinuity occurs after the index is changed. + // Therefore, the only source that causes a discrepancy would be autoplay, + // which can only offset the current track by +1. + if (newWindowIndex != playQueue.getIndex() && playbackManager != null) { + playQueue.offsetIndex(+1); + playbackManager.load(); + } } @Override - public void onPositionDiscontinuity() { + public void onRepeatModeChanged(int i) { + if (DEBUG) Log.d(TAG, "onRepeatModeChanged() called with: mode = [" + i + "]"); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void block() { + if (simpleExoPlayer == null) return; + if (DEBUG) Log.d(TAG, "Blocking..."); + + simpleExoPlayer.stop(); + isPrepared = false; + + changeState(STATE_BLOCKED); + } + + @Override + public void unblock(final MediaSource mediaSource) { + if (simpleExoPlayer == null) return; + if (DEBUG) Log.d(TAG, "Unblocking..."); + + if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); + + simpleExoPlayer.prepare(mediaSource); + simpleExoPlayer.seekToDefaultPosition(); + } + + @Override + public void sync(@android.support.annotation.NonNull final PlayQueueItem item, + @Nullable final StreamInfo info) { + if (simpleExoPlayer == null) return; + if (DEBUG) Log.d(TAG, "Syncing..."); + + currentItem = item; + currentInfo = info; + + // Check if on wrong window + final int currentSourceIndex = playQueue.getIndex(); + if (simpleExoPlayer.getCurrentWindowIndex() != currentSourceIndex) { + final long startPos = info != null ? info.start_position : 0; + if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + " at: " + getTimeString((int)startPos)); + simpleExoPlayer.seekTo(currentSourceIndex, startPos); + } + + initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); + } + + @Override + public void shutdown() { + if (DEBUG) Log.d(TAG, "Shutting down..."); + destroy(); } /*////////////////////////////////////////////////////////////////////////// // General Player //////////////////////////////////////////////////////////////////////////*/ - public void onError(Exception exception){ - destroy(); + public void showStreamError(Exception exception) { + exception.printStackTrace(); + + if (errorToast == null) { + errorToast = Toast.makeText(context, R.string.player_stream_failure, Toast.LENGTH_SHORT); + errorToast.show(); + } + } + + public void showRecoverableError(Exception exception) { + exception.printStackTrace(); + + if (errorToast == null) { + errorToast = Toast.makeText(context, R.string.player_recoverable_failure, Toast.LENGTH_SHORT); + errorToast.show(); + } + } + + public void showUnrecoverableError(Exception exception) { + exception.printStackTrace(); + + if (errorToast != null) { + errorToast.cancel(); + } + errorToast = Toast.makeText(context, R.string.player_unrecoverable_failure, Toast.LENGTH_SHORT); + errorToast.show(); } public void onPrepared(boolean playWhenReady) { if (DEBUG) Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); - if (playWhenReady) audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + if (playWhenReady) audioReactor.requestAudioFocus(); changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); } @@ -559,26 +699,23 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O public void onVideoPlayPause() { if (DEBUG) Log.d(TAG, "onVideoPlayPause() called"); - if (currentState == STATE_COMPLETED) { - onVideoPlayPauseRepeat(); - return; + if (!isPlaying()) { + audioReactor.requestAudioFocus(); + } else { + audioReactor.abandonAudioFocus(); } - if (!isPlaying()) audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - else audioManager.abandonAudioFocus(this); + if (getCurrentState() == STATE_COMPLETED) { + if (playQueue.getIndex() == 0) { + simpleExoPlayer.seekToDefaultPosition(); + } else { + playQueue.setIndex(0); + } + } simpleExoPlayer.setPlayWhenReady(!isPlaying()); } - public void onVideoPlayPauseRepeat() { - if (DEBUG) Log.d(TAG, "onVideoPlayPauseRepeat() called"); - changeState(STATE_LOADING); - setVideoStartPos(0); - simpleExoPlayer.seekTo(0); - simpleExoPlayer.setPlayWhenReady(true); - audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - } - public void onFastRewind() { if (DEBUG) Log.d(TAG, "onFastRewind() called"); seekBy(-FAST_FORWARD_REWIND_AMOUNT); @@ -589,8 +726,40 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O seekBy(FAST_FORWARD_REWIND_AMOUNT); } - public void onThumbnailReceived(Bitmap thumbnail) { - if (DEBUG) Log.d(TAG, "onThumbnailReceived() called with: thumbnail = [" + thumbnail + "]"); + public void onPlayPrevious() { + if (simpleExoPlayer == null || playQueue == null) return; + if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); + + /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, restart current track. + * Also restart the track if the current track is the first in a queue.*/ + if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || playQueue.getIndex() == 0) { + final long startPos = currentInfo == null ? 0 : currentInfo.start_position; + simpleExoPlayer.seekTo(startPos); + } else { + playQueue.offsetIndex(-1); + } + } + + public void onPlayNext() { + if (playQueue == null) return; + if (DEBUG) Log.d(TAG, "onPlayNext() called"); + + playQueue.offsetIndex(+1); + } + + public void onSelected(final PlayQueueItem item) { + final int index = playQueue.indexOf(item); + if (index == -1) return; + + if (playQueue.getIndex() == index) { + simpleExoPlayer.seekToDefaultPosition(); + } else { + playQueue.setIndex(index); + } + + if (!isPlaying()) { + onVideoPlayPause(); + } } public void seekBy(int milliSeconds) { @@ -602,94 +771,42 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O simpleExoPlayer.seekTo(progress); } - public boolean isPlaying() { - return simpleExoPlayer.getPlaybackState() == Player.STATE_READY && simpleExoPlayer.getPlayWhenReady(); + public boolean isCurrentWindowValid() { + return simpleExoPlayer != null && simpleExoPlayer.getDuration() >= 0 + && simpleExoPlayer.getCurrentPosition() >= 0; } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ - private final StringBuilder stringBuilder = new StringBuilder(); - private final Formatter formatter = new Formatter(stringBuilder, Locale.getDefault()); - private final NumberFormat speedFormatter = new DecimalFormat("0.##x"); - - public String getTimeString(int milliSeconds) { - long seconds = (milliSeconds % 60000L) / 1000L; - long minutes = (milliSeconds % 3600000L) / 60000L; - long hours = (milliSeconds % 86400000L) / 3600000L; - long days = (milliSeconds % (86400000L * 7L)) / 86400000L; - - stringBuilder.setLength(0); - return days > 0 ? formatter.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds).toString() - : hours > 0 ? formatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() - : formatter.format("%02d:%02d", minutes, seconds).toString(); - } - - protected String formatSpeed(float speed) { - return speedFormatter.format(speed); - } - - protected void startProgressLoop() { - progressLoop.removeCallbacksAndMessages(null); - isProgressLoopRunning.set(true); - progressLoop.post(progressUpdate); - } - - protected void stopProgressLoop() { - isProgressLoopRunning.set(false); - progressLoop.removeCallbacksAndMessages(null); - } - - protected void tryDeleteCacheFiles(Context context) { - File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - - if (cacheDir.exists()) { - try { - if (cacheDir.isDirectory()) { - for (File file : cacheDir.listFiles()) { - try { - if (DEBUG) Log.d(TAG, "tryDeleteCacheFiles: " + file.getAbsolutePath() + " deleted = " + file.delete()); - } catch (Exception ignored) { - } - } - } - } catch (Exception ignored) { - } + protected void reload() { + if (playbackManager != null) { + playbackManager.reset(); + playbackManager.load(); } } - public void triggerProgressUpdate() { - onUpdateProgress((int) simpleExoPlayer.getCurrentPosition(), (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); + protected void clearThumbnailCache() { + ImageLoader.getInstance().clearMemoryCache(); } - public void animateAudio(final float from, final float to, int duration) { - ValueAnimator valueAnimator = new ValueAnimator(); - valueAnimator.setFloatValues(from, to); - valueAnimator.setDuration(duration); - valueAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - if (simpleExoPlayer != null) simpleExoPlayer.setVolume(from); - } + protected void startProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = getProgressReactor(); + } - @Override - public void onAnimationCancel(Animator animation) { - if (simpleExoPlayer != null) simpleExoPlayer.setVolume(to); - } + protected void stopProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = null; + } - @Override - public void onAnimationEnd(Animator animation) { - if (simpleExoPlayer != null) simpleExoPlayer.setVolume(to); - } - }); - valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - if (simpleExoPlayer != null) simpleExoPlayer.setVolume(((float) animation.getAnimatedValue())); - } - }); - valueAnimator.start(); + public void triggerProgressUpdate() { + onUpdateProgress( + (int) simpleExoPlayer.getCurrentPosition(), + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage() + ); } /*////////////////////////////////////////////////////////////////////////// @@ -700,16 +817,8 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O return simpleExoPlayer; } - public SharedPreferences getSharedPreferences() { - return sharedPreferences; - } - - public RepeatMode getCurrentRepeatMode() { - return currentRepeatMode; - } - - public void setCurrentRepeatMode(RepeatMode mode) { - currentRepeatMode = mode; + public AudioReactor getAudioReactor() { + return audioReactor; } public int getCurrentState() { @@ -717,70 +826,87 @@ public abstract class BasePlayer implements Player.EventListener, AudioManager.O } public String getVideoUrl() { - return videoUrl; - } - - public void setVideoUrl(String videoUrl) { - this.videoUrl = videoUrl; - } - - public long getVideoStartPos() { - return videoStartPos; - } - - public void setVideoStartPos(long videoStartPos) { - this.videoStartPos = videoStartPos; + return currentItem == null ? null : currentItem.getUrl(); } public String getVideoTitle() { - return videoTitle; - } - - public void setVideoTitle(String videoTitle) { - this.videoTitle = videoTitle; + return currentItem == null ? null : currentItem.getTitle(); } public String getUploaderName() { - return uploaderName; - } - - public void setUploaderName(String uploaderName) { - this.uploaderName = uploaderName; + return currentItem == null ? null : currentItem.getUploader(); } public boolean isCompleted() { return simpleExoPlayer != null && simpleExoPlayer.getPlaybackState() == Player.STATE_ENDED; } - public boolean isPrepared() { - return isPrepared; + public boolean isPlaying() { + return simpleExoPlayer.getPlaybackState() == Player.STATE_READY && simpleExoPlayer.getPlayWhenReady(); } - public void setPrepared(boolean prepared) { - isPrepared = prepared; + public int getRepeatMode() { + return simpleExoPlayer.getRepeatMode(); } - public Bitmap getVideoThumbnail() { - return videoThumbnail; - } - - public void setVideoThumbnail(Bitmap videoThumbnail) { - this.videoThumbnail = videoThumbnail; - } - - public String getVideoThumbnailUrl() { - return videoThumbnailUrl; - } - - public void setVideoThumbnailUrl(String videoThumbnailUrl) { - this.videoThumbnailUrl = videoThumbnailUrl; + public void setRepeatMode(final int repeatMode) { + simpleExoPlayer.setRepeatMode(repeatMode); } public float getPlaybackSpeed() { - return simpleExoPlayer.getPlaybackParameters().speed; + return getPlaybackParameters().speed; + } + + public float getPlaybackPitch() { + return getPlaybackParameters().pitch; } public void setPlaybackSpeed(float speed) { - simpleExoPlayer.setPlaybackParameters(new PlaybackParameters(speed, 1f)); + setPlaybackParameters(speed, getPlaybackPitch()); + } + + public void setPlaybackPitch(float pitch) { + setPlaybackParameters(getPlaybackSpeed(), pitch); + } + + public PlaybackParameters getPlaybackParameters() { + final PlaybackParameters parameters = simpleExoPlayer.getPlaybackParameters(); + return parameters == null ? new PlaybackParameters(1f, 1f) : parameters; + } + + public void setPlaybackParameters(float speed, float pitch) { + simpleExoPlayer.setPlaybackParameters(new PlaybackParameters(speed, pitch)); + } + + public PlayQueue getPlayQueue() { + return playQueue; + } + + public PlayQueueAdapter getPlayQueueAdapter() { + return playQueueAdapter; + } + + public boolean isPlayerReady() { + return currentState == STATE_PLAYING || currentState == STATE_COMPLETED || currentState == STATE_PAUSED; + } + + public boolean isProgressLoopRunning() { + return progressUpdateReactor != null && !progressUpdateReactor.isDisposed(); + } + + public void setRecovery() { + if (playQueue == null || simpleExoPlayer == null) return; + + final int queuePos = playQueue.getIndex(); + final long windowPos = simpleExoPlayer.getCurrentPosition(); + + setRecovery(queuePos, windowPos); + } + + public void setRecovery(final int queuePos, final long windowPos) { + if (playQueue.size() <= queuePos) return; + + if (DEBUG) Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); + playQueue.setRecovery(queuePos, windowPos); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 301200dfc..c275e55a7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -27,7 +27,10 @@ import android.graphics.Color; import android.media.AudioManager; import android.os.Build; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; import android.util.Log; import android.view.GestureDetector; import android.view.MotionEvent; @@ -35,16 +38,28 @@ import android.view.View; import android.view.WindowManager; import android.widget.ImageButton; import android.widget.PopupMenu; +import android.widget.RelativeLayout; import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; +import com.google.android.exoplayer2.Player; + import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.PlayQueueItemBuilder; +import org.schabi.newpipe.playlist.PlayQueueItemHolder; import org.schabi.newpipe.util.AnimationUtils; +import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; +import java.util.List; + import static org.schabi.newpipe.util.AnimationUtils.animateView; /** @@ -52,11 +67,10 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView; * * @author mauriciocolli */ -public class MainVideoPlayer extends Activity { +public final class MainVideoPlayer extends Activity { private static final String TAG = ".MainVideoPlayer"; private static final boolean DEBUG = BasePlayer.DEBUG; - private AudioManager audioManager; private GestureDetector gestureDetector; private boolean activityPaused; @@ -73,7 +87,6 @@ public class MainVideoPlayer extends Activity { ThemeHelper.setTheme(this); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK); setVolumeControlStream(AudioManager.STREAM_MUSIC); - audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); if (getIntent() == null) { Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); @@ -83,7 +96,7 @@ public class MainVideoPlayer extends Activity { showSystemUi(); setContentView(R.layout.activity_main_player); - playerImpl = new VideoPlayerImpl(); + playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); playerImpl.handleIntent(getIntent()); } @@ -107,8 +120,10 @@ public class MainVideoPlayer extends Activity { super.onStop(); if (DEBUG) Log.d(TAG, "onStop() called"); activityPaused = true; + if (playerImpl.getPlayer() != null) { - playerImpl.setVideoStartPos((int) playerImpl.getPlayer().getCurrentPosition()); + playerImpl.wasPlaying = playerImpl.getPlayer().getPlayWhenReady(); + playerImpl.setRecovery(); playerImpl.destroyPlayer(); } } @@ -120,7 +135,10 @@ public class MainVideoPlayer extends Activity { if (activityPaused) { playerImpl.initPlayer(); playerImpl.getPlayPauseButton().setImageResource(R.drawable.ic_play_arrow_white); - playerImpl.play(false); + + playerImpl.getPlayer().setPlayWhenReady(playerImpl.wasPlaying); + playerImpl.initPlayback(playerImpl.playQueue); + activityPaused = false; } } @@ -138,6 +156,7 @@ public class MainVideoPlayer extends Activity { private void showSystemUi() { if (DEBUG) Log.d(TAG, "showSystemUi() called"); + if (playerImpl != null && playerImpl.queueVisible) return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE @@ -168,6 +187,29 @@ public class MainVideoPlayer extends Activity { : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); } + protected void setRepeatModeButton(final ImageButton imageButton, final int repeatMode) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + imageButton.setImageResource(R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + imageButton.setImageResource(R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + imageButton.setImageResource(R.drawable.exo_controls_repeat_all); + break; + } + } + + protected void setShuffleButton(final ImageButton shuffleButton, final boolean shuffled) { + final int shuffleAlpha = shuffled ? 255 : 77; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + shuffleButton.setImageAlpha(shuffleAlpha); + } else { + shuffleButton.setAlpha(shuffleAlpha); + } + } + /////////////////////////////////////////////////////////////////////////// @SuppressWarnings({"unused", "WeakerAccess"}) @@ -176,13 +218,24 @@ public class MainVideoPlayer extends Activity { private TextView channelTextView; private TextView volumeTextView; private TextView brightnessTextView; + private ImageButton queueButton; private ImageButton repeatButton; + private ImageButton shuffleButton; private ImageButton screenRotationButton; private ImageButton playPauseButton; + private ImageButton playPreviousButton; + private ImageButton playNextButton; - VideoPlayerImpl() { - super("VideoPlayerImpl" + MainVideoPlayer.TAG, MainVideoPlayer.this); + private RelativeLayout queueLayout; + private ImageButton itemsListCloseButton; + private RecyclerView itemsList; + private ItemTouchHelper itemTouchHelper; + + private boolean queueVisible; + + VideoPlayerImpl(final Context context) { + super("VideoPlayerImpl" + MainVideoPlayer.TAG, context); } @Override @@ -192,16 +245,17 @@ public class MainVideoPlayer extends Activity { this.channelTextView = rootView.findViewById(R.id.channelTextView); this.volumeTextView = rootView.findViewById(R.id.volumeTextView); this.brightnessTextView = rootView.findViewById(R.id.brightnessTextView); + this.queueButton = rootView.findViewById(R.id.queueButton); this.repeatButton = rootView.findViewById(R.id.repeatButton); + this.shuffleButton = rootView.findViewById(R.id.shuffleButton); this.screenRotationButton = rootView.findViewById(R.id.screenRotationButton); this.playPauseButton = rootView.findViewById(R.id.playPauseButton); + this.playPreviousButton = rootView.findViewById(R.id.playPreviousButton); + this.playNextButton = rootView.findViewById(R.id.playNextButton); - // Due to a bug on lower API, lets set the alpha instead of using a drawable - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) repeatButton.setImageAlpha(77); - else { //noinspection deprecation - repeatButton.setAlpha(77); - } + titleTextView.setSelected(true); + channelTextView.setSelected(true); getRootView().setKeepScreenOn(true); } @@ -213,30 +267,63 @@ public class MainVideoPlayer extends Activity { MySimpleOnGestureListener listener = new MySimpleOnGestureListener(); gestureDetector = new GestureDetector(context, listener); gestureDetector.setIsLongpressEnabled(false); - playerImpl.getRootView().setOnTouchListener(listener); + getRootView().setOnTouchListener(listener); + queueButton.setOnClickListener(this); repeatButton.setOnClickListener(this); + shuffleButton.setOnClickListener(this); + playPauseButton.setOnClickListener(this); + playPreviousButton.setOnClickListener(this); + playNextButton.setOnClickListener(this); screenRotationButton.setOnClickListener(this); } + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Video Listener + //////////////////////////////////////////////////////////////////////////*/ + @Override - public void handleIntent(Intent intent) { - super.handleIntent(intent); - titleTextView.setText(getVideoTitle()); - channelTextView.setText(getUploaderName()); + public void onRepeatModeChanged(int i) { + super.onRepeatModeChanged(i); + updatePlaybackButtons(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void shutdown() { + super.shutdown(); + finish(); } @Override - public void playUrl(String url, String format, boolean autoPlay) { - super.playUrl(url, format, autoPlay); - playPauseButton.setImageResource(autoPlay ? R.drawable.ic_pause_white : R.drawable.ic_play_arrow_white); + public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { + super.sync(item, info); + titleTextView.setText(getVideoTitle()); + channelTextView.setText(getUploaderName()); + + playPauseButton.setImageResource(R.drawable.ic_pause_white); } + @Override + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlaybackButtons(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Player Overrides + //////////////////////////////////////////////////////////////////////////*/ + @Override public void onFullScreenButtonClicked() { + super.onFullScreenButtonClicked(); + if (DEBUG) Log.d(TAG, "onFullScreenButtonClicked() called"); - if (playerImpl.getPlayer() == null) return; + if (simpleExoPlayer == null) return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !PermissionHelper.checkSystemAlertWindowPermission(MainVideoPlayer.this)) { @@ -244,48 +331,55 @@ public class MainVideoPlayer extends Activity { return; } - context.startService(NavigationHelper.getOpenVideoPlayerIntent(context, PopupVideoPlayer.class, playerImpl)); - if (playerImpl != null) playerImpl.destroyPlayer(); + setRecovery(); + final Intent intent = NavigationHelper.getPlayerIntent( + context, + PopupVideoPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackQuality() + ); + context.startService(intent); ((View) getControlAnimationView().getParent()).setVisibility(View.GONE); - MainVideoPlayer.this.finish(); - } - - @Override - @SuppressWarnings("deprecation") - public void onRepeatClicked() { - super.onRepeatClicked(); - if (DEBUG) Log.d(TAG, "onRepeatClicked() called"); - switch (getCurrentRepeatMode()) { - case REPEAT_DISABLED: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) repeatButton.setImageAlpha(77); - else repeatButton.setAlpha(77); - - break; - case REPEAT_ONE: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) repeatButton.setImageAlpha(255); - else repeatButton.setAlpha(255); - - break; - case REPEAT_ALL: - // Waiting :) - break; - } + destroy(); + finish(); } @Override public void onClick(View v) { super.onClick(v); - if (v.getId() == repeatButton.getId()) onRepeatClicked(); - else if (v.getId() == playPauseButton.getId()) onVideoPlayPause(); - else if (v.getId() == screenRotationButton.getId()) onScreenRotationClicked(); + if (v.getId() == playPauseButton.getId()) { + onVideoPlayPause(); + + } else if (v.getId() == playPreviousButton.getId()) { + onPlayPrevious(); + + } else if (v.getId() == playNextButton.getId()) { + onPlayNext(); + + } else if (v.getId() == screenRotationButton.getId()) { + onScreenRotationClicked(); + + } else if (v.getId() == queueButton.getId()) { + onQueueClicked(); + return; + } else if (v.getId() == repeatButton.getId()) { + onRepeatClicked(); + return; + } else if (v.getId() == shuffleButton.getId()) { + onShuffleClicked(); + return; + } if (getCurrentState() != STATE_COMPLETED) { getControlsVisibilityHandler().removeCallbacksAndMessages(null); - animateView(playerImpl.getControlsRoot(), true, 300, 0, new Runnable() { + animateView(getControlsRoot(), true, 300, 0, new Runnable() { @Override public void run() { - if (getCurrentState() == STATE_PLAYING && !playerImpl.isSomePopupMenuVisible()) { + if (getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible()) { hideControls(300, DEFAULT_CONTROLS_HIDE_TIME); } } @@ -293,6 +387,24 @@ public class MainVideoPlayer extends Activity { } } + private void onQueueClicked() { + queueVisible = true; + hideSystemUi(); + + buildQueue(); + updatePlaybackButtons(); + + getControlsRoot().setVisibility(View.INVISIBLE); + queueLayout.setVisibility(View.VISIBLE); + + itemsList.smoothScrollToPosition(playQueue.getIndex()); + } + + private void onQueueClosed() { + queueLayout.setVisibility(View.GONE); + queueVisible = false; + } + private void onScreenRotationClicked() { if (DEBUG) Log.d(TAG, "onScreenRotationClicked() called"); toggleOrientation(); @@ -301,7 +413,7 @@ public class MainVideoPlayer extends Activity { @Override public void onStopTrackingTouch(SeekBar seekBar) { super.onStopTrackingTouch(seekBar); - if (playerImpl.wasPlaying()) { + if (wasPlaying()) { hideControls(100, 0); } } @@ -313,28 +425,38 @@ public class MainVideoPlayer extends Activity { } @Override - public void onError(Exception exception) { - exception.printStackTrace(); - Toast.makeText(context, "Failed to play this video", Toast.LENGTH_SHORT).show(); - finish(); + protected int getDefaultResolutionIndex(final List sortedVideos) { + return ListHelper.getDefaultResolutionIndex(context, sortedVideos); + } + + @Override + protected int getOverrideResolutionIndex(final List sortedVideos, + final String playbackQuality) { + return ListHelper.getDefaultResolutionIndex(context, sortedVideos, playbackQuality); } /*////////////////////////////////////////////////////////////////////////// // States //////////////////////////////////////////////////////////////////////////*/ + private void animatePlayButtons(final boolean show, final int duration) { + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + animateView(playPreviousButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + animateView(playNextButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + } + @Override - public void onLoading() { - super.onLoading(); + public void onBlocked() { + super.onBlocked(); playPauseButton.setImageResource(R.drawable.ic_pause_white); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 100); + animatePlayButtons(false, 100); getRootView().setKeepScreenOn(true); } @Override public void onBuffering() { super.onBuffering(); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 100); + animatePlayButtons(false, 100); getRootView().setKeepScreenOn(true); } @@ -345,7 +467,7 @@ public class MainVideoPlayer extends Activity { @Override public void run() { playPauseButton.setImageResource(R.drawable.ic_pause_white); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, true, 200); + animatePlayButtons(true, 200); } }); showSystemUi(); @@ -359,7 +481,7 @@ public class MainVideoPlayer extends Activity { @Override public void run() { playPauseButton.setImageResource(R.drawable.ic_play_arrow_white); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, true, 200); + animatePlayButtons(true, 200); } }); @@ -370,25 +492,22 @@ public class MainVideoPlayer extends Activity { @Override public void onPausedSeek() { super.onPausedSeek(); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 100); + animatePlayButtons(false, 100); getRootView().setKeepScreenOn(true); } @Override public void onCompleted() { - if (getCurrentRepeatMode() == RepeatMode.REPEAT_ONE) { - playPauseButton.setImageResource(R.drawable.ic_pause_white); - } else { - showSystemUi(); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, new Runnable() { - @Override - public void run() { - playPauseButton.setImageResource(R.drawable.ic_replay_white); - animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, true, 300); - } - }); - } + showSystemUi(); + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, new Runnable() { + @Override + public void run() { + playPauseButton.setImageResource(R.drawable.ic_replay_white); + animatePlayButtons(true, 300); + } + }); + getRootView().setKeepScreenOn(false); super.onCompleted(); } @@ -397,6 +516,20 @@ public class MainVideoPlayer extends Activity { // Utils //////////////////////////////////////////////////////////////////////////*/ + @Override + public void showControlsThenHide() { + if (queueVisible) return; + + super.showControlsThenHide(); + } + + @Override + public void showControls(long duration) { + if (queueVisible) return; + + super.showControls(duration); + } + @Override public void hideControls(final long duration, long delay) { if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); @@ -414,6 +547,86 @@ public class MainVideoPlayer extends Activity { }, delay); } + private void updatePlaybackButtons() { + if (repeatButton == null || shuffleButton == null || + simpleExoPlayer == null || playQueue == null) return; + + setRepeatModeButton(repeatButton, getRepeatMode()); + setShuffleButton(shuffleButton, playQueue.isShuffled()); + } + + private void buildQueue() { + queueLayout = findViewById(R.id.playQueuePanel); + + itemsListCloseButton = findViewById(R.id.playQueueClose); + + itemsList = findViewById(R.id.playQueue); + itemsList.setAdapter(playQueueAdapter); + itemsList.setClickable(true); + itemsList.setLongClickable(true); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + playQueueAdapter.setSelectedListener(getOnSelectedListener()); + + itemsListCloseButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onQueueClosed(); + } + }); + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + final int sourceIndex = source.getLayoutPosition(); + final int targetIndex = target.getLayoutPosition(); + playQueue.move(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(PlayQueueItem item, View view) { + onSelected(item); + } + + @Override + public void held(PlayQueueItem item, View view) { + final int index = playQueue.indexOf(item); + if (index != -1) playQueue.remove(index); + } + + @Override + public void onStartDrag(PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) itemTouchHelper.startDrag(viewHolder); + } + }; + } + /////////////////////////////////////////////////////////////////////////// // Getters /////////////////////////////////////////////////////////////////////////// @@ -441,11 +654,6 @@ public class MainVideoPlayer extends Activity { public ImageButton getPlayPauseButton() { return playPauseButton; } - - @Override - public void onRepeatModeChanged(int i) { - - } } private class MySimpleOnGestureListener extends GestureDetector.SimpleOnGestureListener implements View.OnTouchListener { @@ -455,15 +663,20 @@ public class MainVideoPlayer extends Activity { public boolean onDoubleTap(MotionEvent e) { if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY()); if (!playerImpl.isPlaying()) return false; - if (e.getX() > playerImpl.getRootView().getWidth() / 2) playerImpl.onFastForward(); - else playerImpl.onFastRewind(); + + if (e.getX() > playerImpl.getRootView().getWidth() / 2) { + playerImpl.onFastForward(); + } else { + playerImpl.onFastRewind(); + } + return true; } @Override public boolean onSingleTapConfirmed(MotionEvent e) { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); - if (playerImpl.getCurrentState() != BasePlayer.STATE_PLAYING) return true; + if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) return true; if (playerImpl.isControlsVisible()) playerImpl.hideControls(150, 0); else { @@ -473,12 +686,12 @@ public class MainVideoPlayer extends Activity { return true; } - private final boolean isGestureControlsEnabled = playerImpl.getSharedPreferences().getBoolean(getString(R.string.player_gesture_controls_key), true); + private final boolean isPlayerGestureEnabled = PlayerHelper.isPlayerGestureEnabled(getApplicationContext()); private final float stepsBrightness = 15, stepBrightness = (1f / stepsBrightness), minBrightness = .01f; private float currentBrightness = .5f; - private int currentVolume, maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + private int currentVolume, maxVolume = playerImpl.getAudioReactor().getMaxVolume(); private final float stepsVolume = 15, stepVolume = (float) Math.ceil(maxVolume / stepsVolume), minVolume = 0; private final String brightnessUnicode = new String(Character.toChars(0x2600)); @@ -492,7 +705,7 @@ public class MainVideoPlayer extends Activity { // TODO: Improve video gesture controls @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - if (!isGestureControlsEnabled) return false; + if (!isPlayerGestureEnabled) return false; //noinspection PointlessBooleanExpression if (DEBUG && false) Log.d(TAG, "MainVideoPlayer.onScroll = " + @@ -513,13 +726,15 @@ public class MainVideoPlayer extends Activity { if (e1.getX() > playerImpl.getRootView().getWidth() / 2) { double floor = Math.floor(up ? stepVolume : -stepVolume); - currentVolume = (int) (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + floor); + currentVolume = (int) (playerImpl.getAudioReactor().getVolume() + floor); if (currentVolume >= maxVolume) currentVolume = maxVolume; if (currentVolume <= minVolume) currentVolume = (int) minVolume; - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0); + playerImpl.getAudioReactor().setVolume(currentVolume); + currentVolume = playerImpl.getAudioReactor().getVolume(); if (DEBUG) Log.d(TAG, "onScroll().volumeControl, currentVolume = " + currentVolume); - playerImpl.getVolumeTextView().setText(volumeUnicode + " " + Math.round((((float) currentVolume) / maxVolume) * 100) + "%"); + final String volumeText = volumeUnicode + " " + Math.round((((float) currentVolume) / maxVolume) * 100) + "%"; + playerImpl.getVolumeTextView().setText(volumeText); if (playerImpl.getVolumeTextView().getVisibility() != View.VISIBLE) animateView(playerImpl.getVolumeTextView(), true, 200); if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) playerImpl.getBrightnessTextView().setVisibility(View.GONE); @@ -534,7 +749,8 @@ public class MainVideoPlayer extends Activity { if (DEBUG) Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentBrightness); int brightnessNormalized = Math.round(currentBrightness * 100); - playerImpl.getBrightnessTextView().setText(brightnessUnicode + " " + (brightnessNormalized == 1 ? 0 : brightnessNormalized) + "%"); + final String brightnessText = brightnessUnicode + " " + (brightnessNormalized == 1 ? 0 : brightnessNormalized) + "%"; + playerImpl.getBrightnessTextView().setText(brightnessText); if (playerImpl.getBrightnessTextView().getVisibility() != View.VISIBLE) animateView(playerImpl.getBrightnessTextView(), true, 200); if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) playerImpl.getVolumeTextView().setVisibility(View.GONE); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java b/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java new file mode 100644 index 000000000..80c27be7f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java @@ -0,0 +1,16 @@ +package org.schabi.newpipe.player; + +import android.os.Binder; +import android.support.annotation.NonNull; + +class PlayerServiceBinder extends Binder { + private final BasePlayer basePlayer; + + PlayerServiceBinder(@NonNull final BasePlayer basePlayer) { + this.basePlayer = basePlayer; + } + + BasePlayer getPlayerInstance() { + return basePlayer; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index a4087a942..71ce4726a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -35,6 +35,7 @@ import android.os.Handler; import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.util.DisplayMetrics; import android.util.Log; @@ -49,23 +50,25 @@ import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; -import com.nostra13.universalimageloader.core.DisplayImageOptions; -import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.ReCaptchaActivity; -import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.services.youtube.YoutubeStreamExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.old.PlayVideoActivity; +import org.schabi.newpipe.player.helper.LockManager; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.Constants; @@ -75,13 +78,14 @@ import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ThemeHelper; import java.io.IOException; -import java.util.ArrayList; +import java.util.List; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; +import static org.schabi.newpipe.player.helper.PlayerHelper.isUsingOldPlayer; import static org.schabi.newpipe.util.AnimationUtils.animateView; /** @@ -89,7 +93,7 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView; * * @author mauriciocolli */ -public class PopupVideoPlayer extends Service { +public final class PopupVideoPlayer extends Service { private static final String TAG = ".PopupVideoPlayer"; private static final boolean DEBUG = BasePlayer.DEBUG; private static final int SHUTDOWN_FLING_VELOCITY = 10000; @@ -97,7 +101,7 @@ public class PopupVideoPlayer extends Service { private static final int NOTIFICATION_ID = 40028922; public static final String ACTION_CLOSE = "org.schabi.newpipe.player.PopupVideoPlayer.CLOSE"; public static final String ACTION_PLAY_PAUSE = "org.schabi.newpipe.player.PopupVideoPlayer.PLAY_PAUSE"; - public static final String ACTION_OPEN_DETAIL = "org.schabi.newpipe.player.PopupVideoPlayer.OPEN_DETAIL"; + public static final String ACTION_OPEN_CONTROLS = "org.schabi.newpipe.player.PopupVideoPlayer.OPEN_CONTROLS"; public static final String ACTION_REPEAT = "org.schabi.newpipe.player.PopupVideoPlayer.REPEAT"; private static final String POPUP_SAVED_WIDTH = "popup_saved_width"; @@ -114,17 +118,19 @@ public class PopupVideoPlayer extends Service { private float minimumWidth, minimumHeight; private float maximumWidth, maximumHeight; - private final String setAlphaMethodName = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) ? "setImageAlpha" : "setAlpha"; private NotificationManager notificationManager; private NotificationCompat.Builder notBuilder; private RemoteViews notRemoteView; - - private ImageLoader imageLoader = ImageLoader.getInstance(); - private DisplayImageOptions displayImageOptions = new DisplayImageOptions.Builder().cacheInMemory(true).build(); - private VideoPlayerImpl playerImpl; private Disposable currentWorker; + private LockManager lockManager; + /*////////////////////////////////////////////////////////////////////////// + // Service-Activity Binder + //////////////////////////////////////////////////////////////////////////*/ + + private PlayerEventListener activityListener; + private IBinder mBinder; /*////////////////////////////////////////////////////////////////////////// // Service LifeCycle @@ -135,20 +141,21 @@ public class PopupVideoPlayer extends Service { windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); notificationManager = ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)); - playerImpl = new VideoPlayerImpl(); + lockManager = new LockManager(this); + playerImpl = new VideoPlayerImpl(this); ThemeHelper.setTheme(this); + + mBinder = new PlayerServiceBinder(playerImpl); } @Override - @SuppressWarnings("unchecked") public int onStartCommand(final Intent intent, int flags, int startId) { if (DEBUG) Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "], flags = [" + flags + "], startId = [" + startId + "]"); if (playerImpl.getPlayer() == null) initPopup(); if (!playerImpl.isPlaying()) playerImpl.getPlayer().setPlayWhenReady(true); - if (imageLoader != null) imageLoader.clearMemoryCache(); - if (intent.getStringExtra(Constants.KEY_URL) != null) { + if (intent != null && intent.getStringExtra(Constants.KEY_URL) != null) { final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); final String url = intent.getStringExtra(Constants.KEY_URL); @@ -186,19 +193,12 @@ public class PopupVideoPlayer extends Service { @Override public void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy() called"); - stopForeground(true); - if (playerImpl != null) { - playerImpl.destroy(); - if (playerImpl.getRootView() != null) windowManager.removeView(playerImpl.getRootView()); - } - if (notificationManager != null) notificationManager.cancel(NOTIFICATION_ID); - if (currentWorker != null) currentWorker.dispose(); - savePositionAndSize(); + onClose(); } @Override public IBinder onBind(Intent intent) { - return null; + return mBinder; } /*////////////////////////////////////////////////////////////////////////// @@ -237,7 +237,6 @@ public class PopupVideoPlayer extends Service { MySimpleOnGestureListener listener = new MySimpleOnGestureListener(); gestureDetector = new GestureDetector(this, listener); - //gestureDetector.setIsLongpressEnabled(false); rootView.setOnTouchListener(listener); playerImpl.getLoadingPanel().setMinimumWidth(windowLayoutParams.width); playerImpl.getLoadingPanel().setMinimumHeight(windowLayoutParams.height); @@ -248,12 +247,13 @@ public class PopupVideoPlayer extends Service { // Notification //////////////////////////////////////////////////////////////////////////*/ + private void resetNotification() { + notBuilder = createNotification(); + } + private NotificationCompat.Builder createNotification() { notRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, R.layout.player_popup_notification); - if (playerImpl.getVideoThumbnail() == null) notRemoteView.setImageViewResource(R.id.notificationCover, R.drawable.dummy_thumbnail); - else notRemoteView.setImageViewBitmap(R.id.notificationCover, playerImpl.getVideoThumbnail()); - notRemoteView.setTextViewText(R.id.notificationSongName, playerImpl.getVideoTitle()); notRemoteView.setTextViewText(R.id.notificationArtist, playerImpl.getUploaderName()); @@ -262,21 +262,11 @@ public class PopupVideoPlayer extends Service { notRemoteView.setOnClickPendingIntent(R.id.notificationStop, PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_CLOSE), PendingIntent.FLAG_UPDATE_CURRENT)); notRemoteView.setOnClickPendingIntent(R.id.notificationContent, - PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_OPEN_DETAIL), PendingIntent.FLAG_UPDATE_CURRENT)); + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_OPEN_CONTROLS), PendingIntent.FLAG_UPDATE_CURRENT)); notRemoteView.setOnClickPendingIntent(R.id.notificationRepeat, PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_REPEAT), PendingIntent.FLAG_UPDATE_CURRENT)); - switch (playerImpl.getCurrentRepeatMode()) { - case REPEAT_DISABLED: - notRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, 77); - break; - case REPEAT_ONE: - notRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, 255); - break; - case REPEAT_ALL: - // Waiting :) - break; - } + setRepeatModeRemote(notRemoteView, playerImpl.getRepeatMode()); return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) .setOngoing(true) @@ -302,21 +292,33 @@ public class PopupVideoPlayer extends Service { // Misc //////////////////////////////////////////////////////////////////////////*/ - public void onVideoClose() { - if (DEBUG) Log.d(TAG, "onVideoClose() called"); + public void onClose() { + if (DEBUG) Log.d(TAG, "onClose() called"); + + if (playerImpl != null) { + if (playerImpl.getRootView() != null) { + windowManager.removeView(playerImpl.getRootView()); + playerImpl.setRootView(null); + } + playerImpl.stopActivityBinding(); + playerImpl.destroy(); + } + if (lockManager != null) lockManager.releaseWifiAndCpu(); + if (notificationManager != null) notificationManager.cancel(NOTIFICATION_ID); + if (currentWorker != null) currentWorker.dispose(); + mBinder = null; + playerImpl = null; + + stopForeground(true); stopSelf(); } - public void onOpenDetail(Context context, String videoUrl, String videoTitle) { - if (DEBUG) Log.d(TAG, "onOpenDetail() called with: context = [" + context + "], videoUrl = [" + videoUrl + "]"); - Intent i = new Intent(context, MainActivity.class); - i.putExtra(Constants.KEY_SERVICE_ID, 0); - i.putExtra(Constants.KEY_URL, videoUrl); - i.putExtra(Constants.KEY_TITLE, videoTitle); - i.putExtra(Constants.KEY_LINK_TYPE, StreamingService.LinkType.STREAM); - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - i.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - context.startActivity(i); + public void openControl(final Context context) { + final Intent intent = new Intent(context, PopupVideoPlayerActivity.class); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); } @@ -364,7 +366,7 @@ public class PopupVideoPlayer extends Service { } private void updatePopupSize(int width, int height) { - //if (DEBUG) Log.d(TAG, "updatePopupSize() called with: width = [" + width + "], height = [" + height + "]"); + if (DEBUG) Log.d(TAG, "updatePopupSize() called with: width = [" + width + "], height = [" + height + "]"); width = (int) (width > maximumWidth ? maximumWidth : width < minimumWidth ? minimumWidth : width); @@ -380,25 +382,39 @@ public class PopupVideoPlayer extends Service { windowManager.updateViewLayout(playerImpl.getRootView(), windowLayoutParams); } + protected void setRepeatModeRemote(final RemoteViews remoteViews, final int repeatMode) { + final String methodName = "setImageResource"; + + if (remoteViews == null) return; + + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + remoteViews.setInt(R.id.notificationRepeat, methodName, R.drawable.exo_controls_repeat_all); + break; + } + } + /////////////////////////////////////////////////////////////////////////// - private class VideoPlayerImpl extends VideoPlayer { + protected class VideoPlayerImpl extends VideoPlayer { private TextView resizingIndicator; - VideoPlayerImpl() { - super("VideoPlayerImpl" + PopupVideoPlayer.TAG, PopupVideoPlayer.this); + @Override + public void handleIntent(Intent intent) { + super.handleIntent(intent); + + resetNotification(); + startForeground(NOTIFICATION_ID, notBuilder.build()); } - @Override - public void playUrl(String url, String format, boolean autoPlay) { - super.playUrl(url, format, autoPlay); - - windowLayoutParams.width = (int) popupWidth; - windowLayoutParams.height = (int) getMinimumVideoHeight(popupWidth); - windowManager.updateViewLayout(getRootView(), windowLayoutParams); - - notBuilder = createNotification(); - startForeground(NOTIFICATION_ID, notBuilder.build()); + VideoPlayerImpl(final Context context) { + super("VideoPlayerImpl" + PopupVideoPlayer.TAG, context); } @Override @@ -409,58 +425,53 @@ public class PopupVideoPlayer extends Service { @Override public void destroy() { - super.destroy(); if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, null); + super.destroy(); } @Override public void onThumbnailReceived(Bitmap thumbnail) { super.onThumbnailReceived(thumbnail); if (thumbnail != null) { + // rebuild notification here since remote view does not release bitmaps, causing memory leaks + notBuilder = createNotification(); + if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + updateNotification(-1); } } @Override public void onFullScreenButtonClicked() { + super.onFullScreenButtonClicked(); + if (DEBUG) Log.d(TAG, "onFullScreenButtonClicked() called"); + + setRecovery(); Intent intent; - if (!getSharedPreferences().getBoolean(getResources().getString(R.string.use_old_player_key), false)) { - intent = NavigationHelper.getOpenVideoPlayerIntent(context, MainVideoPlayer.class, playerImpl); - if (!playerImpl.isStartedFromNewPipe()) intent.putExtra(VideoPlayer.STARTED_FROM_NEWPIPE, false); + if (!isUsingOldPlayer(getApplicationContext())) { + intent = NavigationHelper.getPlayerIntent( + context, + MainVideoPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackQuality() + ); + if (!isStartedFromNewPipe()) intent.putExtra(VideoPlayer.STARTED_FROM_NEWPIPE, false); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } else { intent = new Intent(PopupVideoPlayer.this, PlayVideoActivity.class) .putExtra(PlayVideoActivity.VIDEO_TITLE, getVideoTitle()) - .putExtra(PlayVideoActivity.STREAM_URL, getSelectedStreamUri().toString()) + .putExtra(PlayVideoActivity.STREAM_URL, getSelectedVideoStream().url) .putExtra(PlayVideoActivity.VIDEO_URL, getVideoUrl()) .putExtra(PlayVideoActivity.START_POSITION, Math.round(getPlayer().getCurrentPosition() / 1000f)); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } context.startActivity(intent); - if (playerImpl != null) playerImpl.destroyPlayer(); - stopSelf(); - } - - @Override - public void onRepeatClicked() { - super.onRepeatClicked(); - switch (getCurrentRepeatMode()) { - case REPEAT_DISABLED: - // Drawable didn't work on low API :/ - //notRemoteView.setImageViewResource(R.id.notificationRepeat, R.drawable.ic_repeat_disabled_white); - // Set the icon to 30% opacity - 255 (max) * .3 - notRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, 77); - break; - case REPEAT_ONE: - notRemoteView.setInt(R.id.notificationRepeat, setAlphaMethodName, 255); - break; - case REPEAT_ALL: - // Waiting :) - break; - } - updateNotification(-1); + onClose(); } @Override @@ -470,20 +481,112 @@ public class PopupVideoPlayer extends Service { } @Override - public void onError(Exception exception) { - exception.printStackTrace(); - Toast.makeText(context, "Failed to play this video", Toast.LENGTH_SHORT).show(); - stopSelf(); + public void onStopTrackingTouch(SeekBar seekBar) { + super.onStopTrackingTouch(seekBar); + if (wasPlaying()) { + hideControls(100, 0); + } } @Override - public void onStopTrackingTouch(SeekBar seekBar) { - super.onStopTrackingTouch(seekBar); - if (playerImpl.wasPlaying()) { - hideControls(100, 0); + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlayback(); + } + + @Override + public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { + updateProgress(currentProgress, duration, bufferPercent); + super.onUpdateProgress(currentProgress, duration, bufferPercent); + } + + @Override + protected int getDefaultResolutionIndex(final List sortedVideos) { + return ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos); + } + + @Override + protected int getOverrideResolutionIndex(final List sortedVideos, + final String playbackQuality) { + return ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos, playbackQuality); + } + + /*////////////////////////////////////////////////////////////////////////// + // Activity Event Listener + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void setActivityListener(PlayerEventListener listener) { + activityListener = listener; + updateMetadata(); + updatePlayback(); + triggerProgressUpdate(); + } + + /*package-private*/ void removeActivityListener(PlayerEventListener listener) { + if (activityListener == listener) { + activityListener = null; } } + private void updateMetadata() { + if (activityListener != null && currentInfo != null) { + activityListener.onMetadataUpdate(currentInfo); + } + } + + private void updatePlayback() { + if (activityListener != null && simpleExoPlayer != null && playQueue != null) { + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); + } + } + + private void updateProgress(int currentProgress, int duration, int bufferPercent) { + if (activityListener != null) { + activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); + } + } + + private void stopActivityBinding() { + if (activityListener != null) { + activityListener.onServiceStopped(); + activityListener = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Video Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onRepeatModeChanged(int i) { + super.onRepeatModeChanged(i); + setRepeatModeRemote(notRemoteView, i); + updateNotification(-1); + updatePlayback(); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + updatePlayback(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void sync(@NonNull PlayQueueItem item, @Nullable StreamInfo info) { + super.sync(item, info); + updateMetadata(); + } + + @Override + public void shutdown() { + super.shutdown(); + onClose(); + } + /*////////////////////////////////////////////////////////////////////////// // Broadcast Receiver //////////////////////////////////////////////////////////////////////////*/ @@ -494,36 +597,53 @@ public class PopupVideoPlayer extends Service { if (DEBUG) Log.d(TAG, "setupBroadcastReceiver() called with: intentFilter = [" + intentFilter + "]"); intentFilter.addAction(ACTION_CLOSE); intentFilter.addAction(ACTION_PLAY_PAUSE); - intentFilter.addAction(ACTION_OPEN_DETAIL); + intentFilter.addAction(ACTION_OPEN_CONTROLS); intentFilter.addAction(ACTION_REPEAT); + + intentFilter.addAction(Intent.ACTION_SCREEN_ON); + intentFilter.addAction(Intent.ACTION_SCREEN_OFF); } @Override public void onBroadcastReceived(Intent intent) { super.onBroadcastReceived(intent); + if (intent == null || intent.getAction() == null) return; if (DEBUG) Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); switch (intent.getAction()) { case ACTION_CLOSE: - onVideoClose(); + onClose(); break; case ACTION_PLAY_PAUSE: - playerImpl.onVideoPlayPause(); + onVideoPlayPause(); break; - case ACTION_OPEN_DETAIL: - onOpenDetail(PopupVideoPlayer.this, playerImpl.getVideoUrl(), playerImpl.getVideoTitle()); + case ACTION_OPEN_CONTROLS: + openControl(getApplicationContext()); break; case ACTION_REPEAT: - playerImpl.onRepeatClicked(); + onRepeatClicked(); + break; + case Intent.ACTION_SCREEN_ON: + enableVideoRenderer(true); + break; + case Intent.ACTION_SCREEN_OFF: + enableVideoRenderer(false); break; } } + /*////////////////////////////////////////////////////////////////////////// // States //////////////////////////////////////////////////////////////////////////*/ @Override - public void onLoading() { - super.onLoading(); + public void changeState(int state) { + super.changeState(state); + updatePlayback(); + } + + @Override + public void onBlocked() { + super.onBlocked(); updateNotification(R.drawable.ic_play_arrow_white); } @@ -531,6 +651,7 @@ public class PopupVideoPlayer extends Service { public void onPlaying() { super.onPlaying(); updateNotification(R.drawable.ic_pause_white); + lockManager.acquireWifiAndCpu(); } @Override @@ -544,6 +665,7 @@ public class PopupVideoPlayer extends Service { super.onPaused(); updateNotification(R.drawable.ic_play_arrow_white); showAndAnimateControl(R.drawable.ic_play_arrow_white, false); + lockManager.releaseWifiAndCpu(); } @Override @@ -557,17 +679,28 @@ public class PopupVideoPlayer extends Service { super.onCompleted(); updateNotification(R.drawable.ic_replay_white); showAndAnimateControl(R.drawable.ic_replay_white, false); + lockManager.releaseWifiAndCpu(); } + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void enableVideoRenderer(final boolean enable) { + final int videoRendererIndex = getVideoRendererIndex(); + if (trackSelector != null && videoRendererIndex != -1) { + trackSelector.setRendererDisabled(videoRendererIndex, !enable); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ @SuppressWarnings("WeakerAccess") public TextView getResizingIndicator() { return resizingIndicator; } - - @Override - public void onRepeatModeChanged(int i) { - } } private class MySimpleOnGestureListener extends GestureDetector.SimpleOnGestureListener implements View.OnTouchListener { @@ -580,16 +713,21 @@ public class PopupVideoPlayer extends Service { public boolean onDoubleTap(MotionEvent e) { if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY()); - if (!playerImpl.isPlaying()) return false; - if (e.getX() > popupWidth / 2) playerImpl.onFastForward(); - else playerImpl.onFastRewind(); + if (playerImpl == null || !playerImpl.isPlaying() || !playerImpl.isPlayerReady()) return false; + + if (e.getX() > popupWidth / 2) { + playerImpl.onFastForward(); + } else { + playerImpl.onFastRewind(); + } + return true; } @Override public boolean onSingleTapConfirmed(MotionEvent e) { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); - if (playerImpl.getPlayer() == null) return false; + if (playerImpl == null || playerImpl.getPlayer() == null) return false; playerImpl.onVideoPlayPause(); return true; } @@ -614,7 +752,7 @@ public class PopupVideoPlayer extends Service { @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - if (isResizing) return super.onScroll(e1, e2, distanceX, distanceY); + if (isResizing || playerImpl == null) return super.onScroll(e1, e2, distanceX, distanceY); if (playerImpl.getCurrentState() != BasePlayer.STATE_BUFFERING && (!isMoving || playerImpl.getControlsRoot().getAlpha() != 1f)) playerImpl.showControls(0); @@ -645,6 +783,7 @@ public class PopupVideoPlayer extends Service { private void onScrollEnd() { if (DEBUG) Log.d(TAG, "onScrollEnd() called"); + if (playerImpl == null) return; if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == BasePlayer.STATE_PLAYING) { playerImpl.hideControls(300, VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME); } @@ -652,9 +791,10 @@ public class PopupVideoPlayer extends Service { @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (playerImpl == null) return false; if (Math.abs(velocityX) > SHUTDOWN_FLING_VELOCITY) { if (DEBUG) Log.d(TAG, "Popup close fling velocity= " + velocityX); - onVideoClose(); + onClose(); return true; } return false; @@ -663,6 +803,7 @@ public class PopupVideoPlayer extends Service { @Override public boolean onTouch(View v, MotionEvent event) { gestureDetector.onTouchEvent(event); + if (playerImpl == null) return false; if (event.getPointerCount() == 2 && !isResizing) { if (DEBUG) Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing."); playerImpl.showAndAnimateControl(-1, true); @@ -739,49 +880,11 @@ public class PopupVideoPlayer extends Service { this.serviceId = serviceId; } - public void onReceive(StreamInfo info) { - playerImpl.setVideoTitle(info.name); - playerImpl.setVideoUrl(info.url); - playerImpl.setVideoThumbnailUrl(info.thumbnail_url); - playerImpl.setUploaderName(info.uploader_name); - - playerImpl.setVideoStreamsList(new ArrayList<>(ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false))); - playerImpl.setAudioStream(ListHelper.getHighestQualityAudio(info.audio_streams)); - - int defaultResolution = ListHelper.getPopupDefaultResolutionIndex(context, playerImpl.getVideoStreamsList()); - playerImpl.setSelectedIndexStream(defaultResolution); - - if (DEBUG) { - Log.d(TAG, "FetcherHandler.StreamExtractor: chosen = " - + MediaFormat.getNameById(info.video_streams.get(defaultResolution).format) + " " - + info.video_streams.get(defaultResolution).resolution + " > " - + info.video_streams.get(defaultResolution).url); - } - - if (info.start_position > 0) playerImpl.setVideoStartPos(info.start_position * 1000); - else playerImpl.setVideoStartPos(-1); - + /*package-private*/ void onReceive(final StreamInfo info) { mainHandler.post(new Runnable() { @Override public void run() { - playerImpl.play(true); - } - }); - - imageLoader.resume(); - imageLoader.loadImage(info.thumbnail_url, displayImageOptions, new SimpleImageLoadingListener() { - @Override - public void onLoadingComplete(final String imageUri, View view, final Bitmap loadedImage) { - if (playerImpl == null || playerImpl.getPlayer() == null) return; - if (DEBUG) Log.d(TAG, "FetcherHandler.imageLoader.onLoadingComplete() called with: imageUri = [" + imageUri + "]"); - mainHandler.post(new Runnable() { - @Override - public void run() { - playerImpl.setVideoThumbnail(loadedImage); - if (loadedImage != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); - updateNotification(-1); - } - }); + playerImpl.initPlayback(new SinglePlayQueue(info)); } }); } @@ -812,7 +915,7 @@ public class PopupVideoPlayer extends Service { stopSelf(); } - public void onReCaptchaException() { + /*package-private*/ void onReCaptchaException() { Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); // Starting ReCaptcha Challenge Activity Intent intent = new Intent(context, ReCaptchaActivity.class); diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayerActivity.java new file mode 100644 index 000000000..2230c9c52 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayerActivity.java @@ -0,0 +1,39 @@ +package org.schabi.newpipe.player; + +import android.content.Intent; + +import org.schabi.newpipe.R; + +public final class PopupVideoPlayerActivity extends ServicePlayerActivity { + + private static final String TAG = "PopupVideoPlayerActivity"; + + @Override + public String getTag() { + return TAG; + } + + @Override + public String getSupportActionTitle() { + return getResources().getString(R.string.title_activity_popup_player); + } + + @Override + public Intent getBindIntent() { + return new Intent(this, PopupVideoPlayer.class); + } + + @Override + public void startPlayerListener() { + if (player != null && player instanceof PopupVideoPlayer.VideoPlayerImpl) { + ((PopupVideoPlayer.VideoPlayerImpl) player).setActivityListener(this); + } + } + + @Override + public void stopPlayerListener() { + if (player != null && player instanceof PopupVideoPlayer.VideoPlayerImpl) { + ((PopupVideoPlayer.VideoPlayerImpl) player).removeActivityListener(this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java new file mode 100644 index 000000000..ef77cdda2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -0,0 +1,562 @@ +package org.schabi.newpipe.player; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.Settings; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.event.PlayerEventListener; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.PlayQueueItemBuilder; +import org.schabi.newpipe.playlist.PlayQueueItemHolder; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ThemeHelper; + +import static org.schabi.newpipe.player.helper.PlayerHelper.formatPitch; +import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; + +public abstract class ServicePlayerActivity extends AppCompatActivity + implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener { + + private boolean serviceBound; + private ServiceConnection serviceConnection; + + protected BasePlayer player; + + private boolean seeking; + private boolean redraw; + //////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////// + + private static final int RECYCLER_ITEM_POPUP_MENU_GROUP_ID = 47; + private static final int PLAYBACK_SPEED_POPUP_MENU_GROUP_ID = 61; + private static final int PLAYBACK_PITCH_POPUP_MENU_GROUP_ID = 97; + + private View rootView; + + private RecyclerView itemsList; + private ItemTouchHelper itemTouchHelper; + + private LinearLayout metadata; + private TextView metadataTitle; + private TextView metadataArtist; + + private SeekBar progressSeekBar; + private TextView progressCurrentTime; + private TextView progressEndTime; + private TextView seekDisplay; + + private ImageButton repeatButton; + private ImageButton backwardButton; + private ImageButton playPauseButton; + private ImageButton forwardButton; + private ImageButton shuffleButton; + private ProgressBar progressBar; + + private TextView playbackSpeedButton; + private PopupMenu playbackSpeedPopupMenu; + private TextView playbackPitchButton; + private PopupMenu playbackPitchPopupMenu; + + //////////////////////////////////////////////////////////////////////////// + // Abstracts + //////////////////////////////////////////////////////////////////////////// + + public abstract String getTag(); + + public abstract String getSupportActionTitle(); + + public abstract Intent getBindIntent(); + + public abstract void startPlayerListener(); + + public abstract void stopPlayerListener(); + + //////////////////////////////////////////////////////////////////////////// + // Activity Lifecycle + //////////////////////////////////////////////////////////////////////////// + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ThemeHelper.setTheme(this); + setContentView(R.layout.activity_player_queue_control); + rootView = findViewById(R.id.main_content); + + final Toolbar toolbar = rootView.findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(getSupportActionTitle()); + } + + serviceConnection = getServiceConnection(); + bind(); + } + + @Override + protected void onResume() { + super.onResume(); + if (redraw) { + recreate(); + redraw = false; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_play_queue, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + case R.id.action_history: + NavigationHelper.openHistory(this); + return true; + case R.id.action_settings: + NavigationHelper.openSettings(this); + redraw = true; + return true; + case R.id.action_system_audio: + startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + unbind(); + } + + //////////////////////////////////////////////////////////////////////////// + // Service Connection + //////////////////////////////////////////////////////////////////////////// + + private void bind() { + final boolean success = bindService(getBindIntent(), serviceConnection, BIND_AUTO_CREATE); + if (!success) { + unbindService(serviceConnection); + } + serviceBound = success; + } + + private void unbind() { + if(serviceBound) { + unbindService(serviceConnection); + serviceBound = false; + stopPlayerListener(); + player = null; + } + } + + private ServiceConnection getServiceConnection() { + return new ServiceConnection() { + @Override + public void onServiceDisconnected(ComponentName name) { + Log.d(getTag(), "Player service is disconnected"); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.d(getTag(), "Player service is connected"); + + if (service instanceof PlayerServiceBinder) { + player = ((PlayerServiceBinder) service).getPlayerInstance(); + } + + if (player == null || player.getPlayQueue() == null || + player.getPlayQueueAdapter() == null || player.getPlayer() == null) { + unbind(); + finish(); + } else { + buildComponents(); + startPlayerListener(); + } + } + }; + } + + //////////////////////////////////////////////////////////////////////////// + // Component Building + //////////////////////////////////////////////////////////////////////////// + + private void buildComponents() { + buildQueue(); + buildMetadata(); + buildSeekBar(); + buildControls(); + } + + private void buildQueue() { + itemsList = findViewById(R.id.play_queue); + itemsList.setLayoutManager(new LinearLayoutManager(this)); + itemsList.setAdapter(player.getPlayQueueAdapter()); + itemsList.setClickable(true); + itemsList.setLongClickable(true); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + player.getPlayQueueAdapter().setSelectedListener(getOnSelectedListener()); + } + + private void buildMetadata() { + metadata = rootView.findViewById(R.id.metadata); + metadataTitle = rootView.findViewById(R.id.song_name); + metadataArtist = rootView.findViewById(R.id.artist_name); + + metadata.setOnClickListener(this); + metadataTitle.setSelected(true); + metadataArtist.setSelected(true); + } + + private void buildSeekBar() { + progressCurrentTime = rootView.findViewById(R.id.current_time); + progressSeekBar = rootView.findViewById(R.id.seek_bar); + progressEndTime = rootView.findViewById(R.id.end_time); + seekDisplay = rootView.findViewById(R.id.seek_display); + + progressSeekBar.setOnSeekBarChangeListener(this); + } + + private void buildControls() { + repeatButton = rootView.findViewById(R.id.control_repeat); + backwardButton = rootView.findViewById(R.id.control_backward); + playPauseButton = rootView.findViewById(R.id.control_play_pause); + forwardButton = rootView.findViewById(R.id.control_forward); + shuffleButton = rootView.findViewById(R.id.control_shuffle); + playbackSpeedButton = rootView.findViewById(R.id.control_playback_speed); + playbackPitchButton = rootView.findViewById(R.id.control_playback_pitch); + progressBar = rootView.findViewById(R.id.control_progress_bar); + + repeatButton.setOnClickListener(this); + backwardButton.setOnClickListener(this); + playPauseButton.setOnClickListener(this); + forwardButton.setOnClickListener(this); + shuffleButton.setOnClickListener(this); + playbackSpeedButton.setOnClickListener(this); + playbackPitchButton.setOnClickListener(this); + + playbackSpeedPopupMenu = new PopupMenu(this, playbackSpeedButton); + playbackPitchPopupMenu = new PopupMenu(this, playbackPitchButton); + buildPlaybackSpeedMenu(); + buildPlaybackPitchMenu(); + } + + private void buildPlaybackSpeedMenu() { + if (playbackSpeedPopupMenu == null) return; + + playbackSpeedPopupMenu.getMenu().removeGroup(PLAYBACK_SPEED_POPUP_MENU_GROUP_ID); + for (int i = 0; i < BasePlayer.PLAYBACK_SPEEDS.length; i++) { + final float playbackSpeed = BasePlayer.PLAYBACK_SPEEDS[i]; + final String formattedSpeed = formatSpeed(playbackSpeed); + final MenuItem item = playbackSpeedPopupMenu.getMenu().add(PLAYBACK_SPEED_POPUP_MENU_GROUP_ID, i, Menu.NONE, formattedSpeed); + item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + player.setPlaybackSpeed(playbackSpeed); + return true; + } + }); + } + } + + private void buildPlaybackPitchMenu() { + if (playbackPitchPopupMenu == null) return; + + playbackPitchPopupMenu.getMenu().removeGroup(PLAYBACK_PITCH_POPUP_MENU_GROUP_ID); + for (int i = 0; i < BasePlayer.PLAYBACK_PITCHES.length; i++) { + final float playbackPitch = BasePlayer.PLAYBACK_PITCHES[i]; + final String formattedPitch = formatPitch(playbackPitch); + final MenuItem item = playbackPitchPopupMenu.getMenu().add(PLAYBACK_PITCH_POPUP_MENU_GROUP_ID, i, Menu.NONE, formattedPitch); + item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + player.setPlaybackPitch(playbackPitch); + return true; + } + }); + } + } + + private void buildItemPopupMenu(final PlayQueueItem item, final View view) { + final PopupMenu menu = new PopupMenu(this, view); + final MenuItem remove = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 0, Menu.NONE, R.string.play_queue_remove); + remove.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + final int index = player.getPlayQueue().indexOf(item); + if (index != -1) player.getPlayQueue().remove(index); + return true; + } + }); + + final MenuItem detail = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 1, Menu.NONE, R.string.play_queue_stream_detail); + detail.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + onOpenDetail(item.getServiceId(), item.getUrl(), item.getTitle()); + return true; + } + }); + + menu.show(); + } + + //////////////////////////////////////////////////////////////////////////// + // Component Helpers + //////////////////////////////////////////////////////////////////////////// + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + final int sourceIndex = source.getLayoutPosition(); + final int targetIndex = target.getLayoutPosition(); + player.getPlayQueue().move(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(PlayQueueItem item, View view) { + player.onSelected(item); + } + + @Override + public void held(PlayQueueItem item, View view) { + final int index = player.getPlayQueue().indexOf(item); + if (index != -1) buildItemPopupMenu(item, view); + } + + @Override + public void onStartDrag(PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) itemTouchHelper.startDrag(viewHolder); + } + }; + } + + private void onOpenDetail(int serviceId, String videoUrl, String videoTitle) { + NavigationHelper.openVideoDetail(this, serviceId, videoUrl, videoTitle); + } + + private void scrollToSelected() { + itemsList.smoothScrollToPosition(player.getPlayQueue().getIndex()); + } + + //////////////////////////////////////////////////////////////////////////// + // Component On-Click Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onClick(View view) { + if (view.getId() == repeatButton.getId()) { + player.onRepeatClicked(); + + } else if (view.getId() == backwardButton.getId()) { + player.onPlayPrevious(); + + } else if (view.getId() == playPauseButton.getId()) { + player.onVideoPlayPause(); + + } else if (view.getId() == forwardButton.getId()) { + player.onPlayNext(); + + } else if (view.getId() == shuffleButton.getId()) { + player.onShuffleClicked(); + + } else if (view.getId() == playbackSpeedButton.getId()) { + playbackSpeedPopupMenu.show(); + + } else if (view.getId() == playbackPitchButton.getId()) { + playbackPitchPopupMenu.show(); + + } else if (view.getId() == metadata.getId()) { + scrollToSelected(); + + } + } + + //////////////////////////////////////////////////////////////////////////// + // Seekbar Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + final String seekTime = Localization.getDurationString(progress / 1000); + progressCurrentTime.setText(seekTime); + seekDisplay.setText(seekTime); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + seeking = true; + seekDisplay.setVisibility(View.VISIBLE); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + player.simpleExoPlayer.seekTo(seekBar.getProgress()); + seekDisplay.setVisibility(View.GONE); + seeking = false; + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onPlaybackUpdate(int state, int repeatMode, boolean shuffled, PlaybackParameters parameters) { + onStateChanged(state); + onPlayModeChanged(repeatMode, shuffled); + onPlaybackParameterChanged(parameters); + } + + @Override + public void onProgressUpdate(int currentProgress, int duration, int bufferPercent) { + // Set buffer progress + progressSeekBar.setSecondaryProgress((int) (progressSeekBar.getMax() * ((float) bufferPercent / 100))); + + // Set Duration + progressSeekBar.setMax(duration); + progressEndTime.setText(Localization.getDurationString(duration / 1000)); + + // Set current time if not seeking + if (!seeking) { + progressSeekBar.setProgress(currentProgress); + progressCurrentTime.setText(Localization.getDurationString(currentProgress / 1000)); + } + } + + @Override + public void onMetadataUpdate(StreamInfo info) { + if (info != null) { + metadataTitle.setText(info.name); + metadataArtist.setText(info.uploader_name); + scrollToSelected(); + } + } + + @Override + public void onServiceStopped() { + unbind(); + finish(); + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Helper + //////////////////////////////////////////////////////////////////////////// + + private void onStateChanged(final int state) { + switch (state) { + case BasePlayer.STATE_PAUSED: + playPauseButton.setImageResource(R.drawable.ic_play_arrow_white); + break; + case BasePlayer.STATE_PLAYING: + playPauseButton.setImageResource(R.drawable.ic_pause_white); + break; + case BasePlayer.STATE_COMPLETED: + playPauseButton.setImageResource(R.drawable.ic_replay_white); + break; + default: + break; + } + + switch (state) { + case BasePlayer.STATE_PAUSED: + case BasePlayer.STATE_PLAYING: + case BasePlayer.STATE_COMPLETED: + playPauseButton.setClickable(true); + playPauseButton.setVisibility(View.VISIBLE); + progressBar.setVisibility(View.GONE); + break; + default: + playPauseButton.setClickable(false); + playPauseButton.setVisibility(View.INVISIBLE); + progressBar.setVisibility(View.VISIBLE); + break; + } + } + + private void onPlayModeChanged(final int repeatMode, final boolean shuffled) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_all); + break; + } + + final int shuffleAlpha = shuffled ? 255 : 77; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + shuffleButton.setImageAlpha(shuffleAlpha); + } else { + shuffleButton.setAlpha(shuffleAlpha); + } + } + + private void onPlaybackParameterChanged(final PlaybackParameters parameters) { + if (parameters != null) { + playbackSpeedButton.setText(formatSpeed(parameters.speed)); + playbackPitchButton.setText(formatPitch(parameters.pitch)); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index fa25cc957..1a386d45d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -29,9 +29,10 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.PorterDuff; -import android.net.Uri; import android.os.Build; import android.os.Handler; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import android.util.Log; import android.view.Menu; @@ -45,9 +46,9 @@ import android.widget.ProgressBar; import android.widget.SeekBar; import android.widget.TextView; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; @@ -55,14 +56,17 @@ import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.util.AnimationUtils; +import org.schabi.newpipe.util.ListHelper; -import java.io.Serializable; import java.util.ArrayList; import java.util.List; -import java.util.Vector; +import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.util.AnimationUtils.animateView; /** @@ -79,24 +83,21 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. // Intent //////////////////////////////////////////////////////////////////////////*/ - public static final String VIDEO_STREAMS_LIST = "video_streams_list"; - public static final String VIDEO_ONLY_AUDIO_STREAM = "video_only_audio_stream"; - public static final String INDEX_SEL_VIDEO_STREAM = "index_selected_video_stream"; public static final String STARTED_FROM_NEWPIPE = "started_from_newpipe"; - private int selectedIndexStream; - private ArrayList videoStreamsList = new ArrayList<>(); - private AudioStream videoOnlyAudioStream; - /*////////////////////////////////////////////////////////////////////////// // Player //////////////////////////////////////////////////////////////////////////*/ public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds - private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f}; + + private ArrayList availableStreams; + private int selectedStreamIndex; + + protected String playbackQuality; private boolean startedFromNewPipe = true; - private boolean wasPlaying = false; + protected boolean wasPlaying = false; /*////////////////////////////////////////////////////////////////////////// // Views @@ -119,7 +120,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. private SeekBar playbackSeekBar; private TextView playbackCurrentTime; private TextView playbackEndTime; - private TextView playbackSpeed; + private TextView playbackSpeedTextView; private View topControlsRoot; private TextView qualityTextView; @@ -129,7 +130,6 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. private Handler controlsVisibilityHandler = new Handler(); private boolean isSomePopupMenuVisible = false; - private boolean qualityChanged = false; private int qualityPopupMenuGroupId = 69; private PopupMenu qualityPopupMenu; @@ -162,7 +162,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. this.playbackSeekBar = rootView.findViewById(R.id.playbackSeekBar); this.playbackCurrentTime = rootView.findViewById(R.id.playbackCurrentTime); this.playbackEndTime = rootView.findViewById(R.id.playbackEndTime); - this.playbackSpeed = rootView.findViewById(R.id.playbackSpeed); + this.playbackSpeedTextView = rootView.findViewById(R.id.playbackSpeed); this.bottomControlsRoot = rootView.findViewById(R.id.bottomControls); this.topControlsRoot = rootView.findViewById(R.id.topControls); this.qualityTextView = rootView.findViewById(R.id.qualityTextView); @@ -175,7 +175,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. this.playbackSeekBar.getProgressDrawable().setColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY); this.qualityPopupMenu = new PopupMenu(context, qualityTextView); - this.playbackSpeedPopupMenu = new PopupMenu(context, playbackSpeed); + this.playbackSpeedPopupMenu = new PopupMenu(context, playbackSpeedTextView); ((ProgressBar) this.loadingPanel.findViewById(R.id.progressBarLoadingPanel)).getIndeterminateDrawable().setColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY); @@ -185,7 +185,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. public void initListeners() { super.initListeners(); playbackSeekBar.setOnSeekBarChangeListener(this); - playbackSpeed.setOnClickListener(this); + playbackSpeedTextView.setOnClickListener(this); fullScreenButton.setOnClickListener(this); qualityTextView.setOnClickListener(this); } @@ -194,80 +194,103 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. public void initPlayer() { super.initPlayer(); simpleExoPlayer.setVideoSurfaceView(surfaceView); - simpleExoPlayer.setVideoListener(this); + simpleExoPlayer.addVideoListener(this); + + if (Build.VERSION.SDK_INT >= 21) { + trackSelector.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context)); + } } - @SuppressWarnings("unchecked") - public void handleIntent(Intent intent) { - super.handleIntent(intent); - if (DEBUG) Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); + @Override + public void handleIntent(final Intent intent) { if (intent == null) return; - selectedIndexStream = intent.getIntExtra(INDEX_SEL_VIDEO_STREAM, -1); - - Serializable serializable = intent.getSerializableExtra(VIDEO_STREAMS_LIST); - - if (serializable instanceof ArrayList) videoStreamsList = (ArrayList) serializable; - if (serializable instanceof Vector) videoStreamsList = new ArrayList<>((List) serializable); - - Serializable audioStream = intent.getSerializableExtra(VIDEO_ONLY_AUDIO_STREAM); - if (audioStream != null) videoOnlyAudioStream = (AudioStream) audioStream; - - startedFromNewPipe = intent.getBooleanExtra(STARTED_FROM_NEWPIPE, true); - play(true); - } - - - public void play(boolean autoPlay) { - playUrl(getSelectedVideoStream().url, MediaFormat.getSuffixById(getSelectedVideoStream().format), autoPlay); - } - - @Override - public void playUrl(String url, String format, boolean autoPlay) { - if (DEBUG) Log.d(TAG, "play() called with: url = [" + url + "], autoPlay = [" + autoPlay + "]"); - qualityChanged = false; - - if (url == null || simpleExoPlayer == null) { - RuntimeException runtimeException = new RuntimeException((url == null ? "Url " : "Player ") + " null"); - onError(runtimeException); - throw runtimeException; + if (intent.hasExtra(PLAYBACK_QUALITY)) { + setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); } + super.handleIntent(intent); + } + + /*////////////////////////////////////////////////////////////////////////// + // UI Builders + //////////////////////////////////////////////////////////////////////////*/ + + public void buildQualityMenu() { + if (qualityPopupMenu == null) return; + qualityPopupMenu.getMenu().removeGroup(qualityPopupMenuGroupId); - buildQualityMenu(qualityPopupMenu); + for (int i = 0; i < availableStreams.size(); i++) { + VideoStream videoStream = availableStreams.get(i); + qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution); + } + qualityTextView.setText(getSelectedVideoStream().resolution); + qualityPopupMenu.setOnMenuItemClickListener(this); + qualityPopupMenu.setOnDismissListener(this); + } + + private void buildPlaybackSpeedMenu() { + if (playbackSpeedPopupMenu == null) return; playbackSpeedPopupMenu.getMenu().removeGroup(playbackSpeedPopupMenuGroupId); - buildPlaybackSpeedMenu(playbackSpeedPopupMenu); + for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { + playbackSpeedPopupMenu.getMenu().add(playbackSpeedPopupMenuGroupId, i, Menu.NONE, formatSpeed(PLAYBACK_SPEEDS[i])); + } + playbackSpeedTextView.setText(formatSpeed(getPlaybackSpeed())); + playbackSpeedPopupMenu.setOnMenuItemClickListener(this); + playbackSpeedPopupMenu.setOnDismissListener(this); + } - super.playUrl(url, format, autoPlay); + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected abstract int getDefaultResolutionIndex(final List sortedVideos); + + protected abstract int getOverrideResolutionIndex(final List sortedVideos, final String playbackQuality); + + @Override + public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { + super.sync(item, info); + qualityTextView.setVisibility(View.GONE); + playbackSpeedTextView.setVisibility(View.GONE); + + if (info != null) { + final List videos = ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false); + availableStreams = new ArrayList<>(videos); + if (playbackQuality == null) { + selectedStreamIndex = getDefaultResolutionIndex(videos); + } else { + selectedStreamIndex = getOverrideResolutionIndex(videos, getPlaybackQuality()); + } + + buildQualityMenu(); + buildPlaybackSpeedMenu(); + qualityTextView.setVisibility(View.VISIBLE); + playbackSpeedTextView.setVisibility(View.VISIBLE); + } } @Override - public MediaSource buildMediaSource(String url, String overrideExtension) { - MediaSource mediaSource = super.buildMediaSource(url, overrideExtension); - if (!getSelectedVideoStream().isVideoOnly || videoOnlyAudioStream == null) return mediaSource; + public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + final List videos = ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false); - Uri audioUri = Uri.parse(videoOnlyAudioStream.url); - return new MergingMediaSource(mediaSource, new ExtractorMediaSource(audioUri, cacheDataSourceFactory, extractorsFactory, null, null)); - } - - public void buildQualityMenu(PopupMenu popupMenu) { - for (int i = 0; i < videoStreamsList.size(); i++) { - VideoStream videoStream = videoStreamsList.get(i); - popupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution); + final VideoStream video; + if (playbackQuality == null) { + final int index = getDefaultResolutionIndex(videos); + video = videos.get(index); + } else { + final int index = getOverrideResolutionIndex(videos, getPlaybackQuality()); + video = videos.get(index); } - qualityTextView.setText(getSelectedVideoStream().resolution); - popupMenu.setOnMenuItemClickListener(this); - popupMenu.setOnDismissListener(this); - } - private void buildPlaybackSpeedMenu(PopupMenu popupMenu) { - for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { - popupMenu.getMenu().add(playbackSpeedPopupMenuGroupId, i, Menu.NONE, formatSpeed(PLAYBACK_SPEEDS[i])); - } - playbackSpeed.setText(formatSpeed(getPlaybackSpeed())); - popupMenu.setOnMenuItemClickListener(this); - popupMenu.setOnDismissListener(this); + final MediaSource streamSource = buildMediaSource(video.url, MediaFormat.getSuffixById(video.format)); + final AudioStream audio = ListHelper.getHighestQualityAudio(info.audio_streams); + if (!video.isVideoOnly || audio == null) return streamSource; + + // Merge with audio stream in case if video does not contain audio + final MediaSource audioSource = buildMediaSource(audio.url, MediaFormat.getSuffixById(audio.format)); + return new MergingMediaSource(streamSource, audioSource); } /*////////////////////////////////////////////////////////////////////////// @@ -275,18 +298,13 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. //////////////////////////////////////////////////////////////////////////*/ @Override - public void onLoading() { - if (DEBUG) Log.d(TAG, "onLoading() called"); - - if (!isProgressLoopRunning.get()) startProgressLoop(); + public void onBlocked() { + super.onBlocked(); controlsVisibilityHandler.removeCallbacksAndMessages(null); animateView(controlsRoot, false, 300); - showAndAnimateControl(-1, true); - playbackSeekBar.setEnabled(true); - playbackSeekBar.setProgress(0); - + playbackSeekBar.setEnabled(false); // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, so sets the color again if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); @@ -299,12 +317,19 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. @Override public void onPlaying() { - if (DEBUG) Log.d(TAG, "onPlaying() called"); - if (!isProgressLoopRunning.get()) startProgressLoop(); + super.onPlaying(); + showAndAnimateControl(-1, true); + + playbackSeekBar.setEnabled(true); + // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, so sets the color again + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) + playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); + loadingPanel.setVisibility(View.GONE); showControlsThenHide(); animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); + animateView(endScreen, false, 0); } @Override @@ -329,30 +354,14 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. @Override public void onCompleted() { - if (DEBUG) Log.d(TAG, "onCompleted() called"); - - if (isProgressLoopRunning.get()) stopProgressLoop(); + super.onCompleted(); showControls(500); animateView(endScreen, true, 800); animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); loadingPanel.setVisibility(View.GONE); - playbackSeekBar.setMax((int) simpleExoPlayer.getDuration()); - playbackSeekBar.setProgress(playbackSeekBar.getMax()); - playbackSeekBar.setEnabled(false); - playbackEndTime.setText(getTimeString(playbackSeekBar.getMax())); - playbackCurrentTime.setText(playbackEndTime.getText()); - // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, so sets the color again - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) - playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); - animateView(surfaceForeground, true, 100); - - if (currentRepeatMode == RepeatMode.REPEAT_ONE) { - changeState(STATE_LOADING); - simpleExoPlayer.seekTo(0); - } } /*////////////////////////////////////////////////////////////////////////// @@ -380,15 +389,9 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. public void onPrepared(boolean playWhenReady) { if (DEBUG) Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); - if (videoStartPos > 0) { - playbackSeekBar.setProgress((int) videoStartPos); - playbackCurrentTime.setText(getTimeString((int) videoStartPos)); - videoStartPos = -1; - } - playbackSeekBar.setMax((int) simpleExoPlayer.getDuration()); playbackEndTime.setText(getTimeString((int) simpleExoPlayer.getDuration())); - playbackSpeed.setText(formatSpeed(getPlaybackSpeed())); + playbackSpeedTextView.setText(formatSpeed(getPlaybackSpeed())); super.onPrepared(playWhenReady); } @@ -403,6 +406,10 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { if (!isPrepared) return; + if (duration != playbackSeekBar.getMax()) { + playbackEndTime.setText(getTimeString(duration)); + playbackSeekBar.setMax(duration); + } if (currentState != STATE_PAUSED) { if (currentState != STATE_PAUSED_SEEK) playbackSeekBar.setProgress(currentProgress); playbackCurrentTime.setText(getTimeString(currentProgress)); @@ -415,22 +422,17 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. } } - @Override - public void onVideoPlayPauseRepeat() { - if (DEBUG) Log.d(TAG, "onVideoPlayPauseRepeat() called"); - if (qualityChanged) { - setVideoStartPos(0); - play(true); - } else super.onVideoPlayPauseRepeat(); - } - @Override public void onThumbnailReceived(Bitmap thumbnail) { super.onThumbnailReceived(thumbnail); if (thumbnail != null) endScreen.setImageBitmap(thumbnail); } - protected abstract void onFullScreenButtonClicked(); + protected void onFullScreenButtonClicked() { + if (!isPlayerReady()) return; + + changeState(STATE_BLOCKED); + } @Override public void onFastRewind() { @@ -455,7 +457,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. onFullScreenButtonClicked(); } else if (v.getId() == qualityTextView.getId()) { onQualitySelectorClicked(); - } else if (v.getId() == playbackSpeed.getId()) { + } else if (v.getId() == playbackSpeedTextView.getId()) { onPlaybackSpeedClicked(); } } @@ -469,12 +471,14 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. Log.d(TAG, "onMenuItemClick() called with: menuItem = [" + menuItem + "], menuItem.getItemId = [" + menuItem.getItemId() + "]"); if (qualityPopupMenuGroupId == menuItem.getGroupId()) { - if (selectedIndexStream == menuItem.getItemId()) return true; - setVideoStartPos(simpleExoPlayer.getCurrentPosition()); + final int menuItemIndex = menuItem.getItemId(); + if (selectedStreamIndex == menuItemIndex || + availableStreams == null || availableStreams.size() <= menuItemIndex) return true; - selectedIndexStream = menuItem.getItemId(); - if (!(getCurrentState() == STATE_COMPLETED)) play(wasPlaying); - else qualityChanged = true; + final String newResolution = availableStreams.get(menuItemIndex).resolution; + setRecovery(); + setPlaybackQuality(newResolution); + reload(); qualityTextView.setText(menuItem.getTitle()); return true; @@ -483,7 +487,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. float speed = PLAYBACK_SPEEDS[speedIndex]; setPlaybackSpeed(speed); - playbackSpeed.setText(formatSpeed(speed)); + playbackSpeedTextView.setText(formatSpeed(speed)); } return false; @@ -505,9 +509,10 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. isSomePopupMenuVisible = true; showControls(300); - VideoStream videoStream = getSelectedVideoStream(); - qualityTextView.setText(MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution); - wasPlaying = isPlaying(); + final VideoStream videoStream = getSelectedVideoStream(); + final String qualityText = MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution; + qualityTextView.setText(qualityText); + wasPlaying = simpleExoPlayer.getPlayWhenReady(); } private void onPlaybackSpeedClicked() { @@ -533,7 +538,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. if (DEBUG) Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); if (getCurrentState() != STATE_PAUSED_SEEK) changeState(STATE_PAUSED_SEEK); - wasPlaying = isPlaying(); + wasPlaying = simpleExoPlayer.getPlayWhenReady(); if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); showControls(0); @@ -551,13 +556,25 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); if (getCurrentState() == STATE_PAUSED_SEEK) changeState(STATE_BUFFERING); - if (!isProgressLoopRunning.get()) startProgressLoop(); + if (!isProgressLoopRunning()) startProgressLoop(); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ + public int getVideoRendererIndex() { + if (simpleExoPlayer == null) return -1; + + for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) { + if (simpleExoPlayer.getRendererType(t) == C.TRACK_TYPE_VIDEO) { + return t; + } + } + + return -1; + } + public boolean isControlsVisible() { return controlsRoot != null && controlsRoot.getVisibility() == View.VISIBLE; } @@ -652,6 +669,14 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. // Getters and Setters //////////////////////////////////////////////////////////////////////////*/ + public void setPlaybackQuality(final String quality) { + this.playbackQuality = quality; + } + + public String getPlaybackQuality() { + return playbackQuality; + } + public AspectRatioFrameLayout getAspectRatioFrameLayout() { return aspectRatioFrameLayout; } @@ -665,39 +690,7 @@ public abstract class VideoPlayer extends BasePlayer implements SimpleExoPlayer. } public VideoStream getSelectedVideoStream() { - return videoStreamsList.get(selectedIndexStream); - } - - public Uri getSelectedStreamUri() { - return Uri.parse(getSelectedVideoStream().url); - } - - public int getQualityPopupMenuGroupId() { - return qualityPopupMenuGroupId; - } - - public int getSelectedStreamIndex() { - return selectedIndexStream; - } - - public void setSelectedIndexStream(int selectedIndexStream) { - this.selectedIndexStream = selectedIndexStream; - } - - public void setAudioStream(AudioStream audioStream) { - this.videoOnlyAudioStream = audioStream; - } - - public AudioStream getAudioStream() { - return videoOnlyAudioStream; - } - - public ArrayList getVideoStreamsList() { - return videoStreamsList; - } - - public void setVideoStreamsList(ArrayList videoStreamsList) { - this.videoStreamsList = videoStreamsList; + return availableStreams.get(selectedStreamIndex); } public boolean isStartedFromNewPipe() { diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java new file mode 100644 index 000000000..3a7b29954 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.player.event; + + +import com.google.android.exoplayer2.PlaybackParameters; + +import org.schabi.newpipe.extractor.stream.StreamInfo; + +public interface PlayerEventListener { + void onPlaybackUpdate(int state, int repeatMode, boolean shuffled, PlaybackParameters parameters); + void onProgressUpdate(int currentProgress, int duration, int bufferPercent); + void onMetadataUpdate(StreamInfo info); + void onServiceStopped(); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java new file mode 100644 index 000000000..4e031a0dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -0,0 +1,188 @@ +package org.schabi.newpipe.player.helper; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.Intent; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.audiofx.AudioEffect; +import android.os.Build; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.decoder.DecoderCounters; + +public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AudioRendererEventListener { + + private static final String TAG = "AudioFocusReactor"; + + private static final int DUCK_DURATION = 1500; + private static final float DUCK_AUDIO_TO = .2f; + + private static final int FOCUS_GAIN_TYPE = AudioManager.AUDIOFOCUS_GAIN; + private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; + + private final SimpleExoPlayer player; + private final Context context; + private final AudioManager audioManager; + + private final AudioFocusRequest request; + + public AudioReactor(@NonNull final Context context, @NonNull final SimpleExoPlayer player) { + this.player = player; + this.context = context; + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + player.setAudioDebugListener(this); + + if (shouldBuildFocusRequest()) { + request = new AudioFocusRequest.Builder(FOCUS_GAIN_TYPE) + .setAcceptsDelayedFocusGain(true) + .setWillPauseWhenDucked(true) + .setOnAudioFocusChangeListener(this) + .build(); + } else { + request = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Manager + //////////////////////////////////////////////////////////////////////////*/ + + public void requestAudioFocus() { + if (shouldBuildFocusRequest()) { + audioManager.requestAudioFocus(request); + } else { + audioManager.requestAudioFocus(this, STREAM_TYPE, FOCUS_GAIN_TYPE); + } + } + + public void abandonAudioFocus() { + if (shouldBuildFocusRequest()) { + audioManager.abandonAudioFocusRequest(request); + } else { + audioManager.abandonAudioFocus(this); + } + } + + public int getVolume() { + return audioManager.getStreamVolume(STREAM_TYPE); + } + + public int getMaxVolume() { + return audioManager.getStreamMaxVolume(STREAM_TYPE); + } + + public void setVolume(final int volume) { + audioManager.setStreamVolume(STREAM_TYPE, volume, 0); + } + + private boolean shouldBuildFocusRequest() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } + + /*////////////////////////////////////////////////////////////////////////// + // AudioFocus + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAudioFocusChange(int focusChange) { + Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]"); + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: + onAudioFocusGain(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + onAudioFocusLossCanDuck(); + break; + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + onAudioFocusLoss(); + break; + } + } + + private void onAudioFocusGain() { + Log.d(TAG, "onAudioFocusGain() called"); + player.setVolume(DUCK_AUDIO_TO); + animateAudio(DUCK_AUDIO_TO, 1f, DUCK_DURATION); + + if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { + player.setPlayWhenReady(true); + } + } + + private void onAudioFocusLoss() { + Log.d(TAG, "onAudioFocusLoss() called"); + player.setPlayWhenReady(false); + } + + private void onAudioFocusLossCanDuck() { + Log.d(TAG, "onAudioFocusLossCanDuck() called"); + // Set the volume to 1/10 on ducking + animateAudio(player.getVolume(), DUCK_AUDIO_TO, DUCK_DURATION); + } + + private void animateAudio(final float from, final float to, int duration) { + ValueAnimator valueAnimator = new ValueAnimator(); + valueAnimator.setFloatValues(from, to); + valueAnimator.setDuration(duration); + valueAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + player.setVolume(from); + } + + @Override + public void onAnimationCancel(Animator animation) { + player.setVolume(to); + } + + @Override + public void onAnimationEnd(Animator animation) { + player.setVolume(to); + } + }); + valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + player.setVolume(((float) animation.getAnimatedValue())); + } + }); + valueAnimator.start(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Processing + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAudioSessionId(int i) { + if (!PlayerHelper.isUsingDSP(context)) return; + + final Intent intent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); + intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, i); + intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); + context.sendBroadcast(intent); + } + + @Override + public void onAudioEnabled(DecoderCounters decoderCounters) {} + + @Override + public void onAudioDecoderInitialized(String s, long l, long l1) {} + + @Override + public void onAudioInputFormatChanged(Format format) {} + + @Override + public void onAudioTrackUnderrun(int i, long l, long l1) {} + + @Override + public void onAudioDisabled(DecoderCounters decoderCounters) {} +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java new file mode 100644 index 000000000..dce74ffb5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -0,0 +1,85 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import org.schabi.newpipe.Downloader; + +import java.io.File; + +public class CacheFactory implements DataSource.Factory { + private static final String TAG = "CacheFactory"; + private static final String CACHE_FOLDER_NAME = "exoplayer"; + private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; + + private final DefaultDataSourceFactory dataSourceFactory; + private final File cacheDir; + private final long maxFileSize; + + // Creating cache on every instance may cause problems with multiple players when + // sources are not ExtractorMediaSource + // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer + // todo: make this a singleton? + private static SimpleCache cache; + + public CacheFactory(@NonNull final Context context) { + this(context, PlayerHelper.getPreferredCacheSize(context), PlayerHelper.getPreferredFileSize(context)); + } + + CacheFactory(@NonNull final Context context, final long maxCacheSize, final long maxFileSize) { + super(); + this.maxFileSize = maxFileSize; + + final String userAgent = Downloader.USER_AGENT; + final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, bandwidthMeter); + + cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (!cacheDir.exists()) { + //noinspection ResultOfMethodCallIgnored + cacheDir.mkdir(); + } + + if (cache == null) { + final LeastRecentlyUsedCacheEvictor evictor = new LeastRecentlyUsedCacheEvictor(maxCacheSize); + cache = new SimpleCache(cacheDir, evictor); + } + } + + @Override + public DataSource createDataSource() { + Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); + + final DefaultDataSource dataSource = dataSourceFactory.createDataSource(); + final FileDataSource fileSource = new FileDataSource(); + final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); + + return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); + } + + public void tryDeleteCacheFiles() { + if (!cacheDir.exists() || !cacheDir.isDirectory()) return; + + try { + for (File file : cacheDir.listFiles()) { + final String filePath = file.getAbsolutePath(); + final boolean deleteSuccessful = file.delete(); + + Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); + } + } catch (Exception ignored) { + Log.e(TAG, "Failed to delete file.", ignored); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java new file mode 100644 index 000000000..acc20f5b0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -0,0 +1,76 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; + +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DefaultAllocator; + +public class LoadController implements LoadControl { + + public static final String TAG = "LoadController"; + + private final LoadControl internalLoadControl; + + /*////////////////////////////////////////////////////////////////////////// + // Default Load Control + //////////////////////////////////////////////////////////////////////////*/ + + public LoadController(final Context context) { + this(PlayerHelper.getMinBufferMs(context), + PlayerHelper.getMaxBufferMs(context), + PlayerHelper.getBufferForPlaybackMs(context), + PlayerHelper.getBufferForPlaybackAfterRebufferMs(context)); + } + + public LoadController(final int minBufferMs, + final int maxBufferMs, + final long bufferForPlaybackMs, + final long bufferForPlaybackAfterRebufferMs) { + final DefaultAllocator allocator = new DefaultAllocator(true, 65536); + internalLoadControl = new DefaultLoadControl(allocator, minBufferMs, maxBufferMs, bufferForPlaybackMs, bufferForPlaybackAfterRebufferMs); + } + + /*////////////////////////////////////////////////////////////////////////// + // Custom behaviours + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onPrepared() { + internalLoadControl.onPrepared(); + } + + @Override + public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroupArray, TrackSelectionArray trackSelectionArray) { + internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); + } + + @Override + public void onStopped() { + internalLoadControl.onStopped(); + } + + @Override + public void onReleased() { + internalLoadControl.onReleased(); + } + + @Override + public Allocator getAllocator() { + return internalLoadControl.getAllocator(); + } + + @Override + public boolean shouldStartPlayback(long l, boolean b) { + return internalLoadControl.shouldStartPlayback(l, b); + } + + @Override + public boolean shouldContinueLoading(long l) { + return internalLoadControl.shouldContinueLoading(l); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java new file mode 100644 index 000000000..1f352159c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java @@ -0,0 +1,44 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.util.Log; + +import static android.content.Context.POWER_SERVICE; +import static android.content.Context.WIFI_SERVICE; + +public class LockManager { + private final String TAG = "LockManager@" + hashCode(); + + private final PowerManager powerManager; + private final WifiManager wifiManager; + + private PowerManager.WakeLock wakeLock; + private WifiManager.WifiLock wifiLock; + + public LockManager(final Context context) { + powerManager = ((PowerManager) context.getApplicationContext().getSystemService(POWER_SERVICE)); + wifiManager = ((WifiManager) context.getApplicationContext().getSystemService(WIFI_SERVICE)); + } + + public void acquireWifiAndCpu() { + Log.d(TAG, "acquireWifiAndCpu() called"); + if (wakeLock != null && wakeLock.isHeld() && wifiLock != null && wifiLock.isHeld()) return; + + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); + wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); + + if (wakeLock != null) wakeLock.acquire(); + if (wifiLock != null) wifiLock.acquire(); + } + + public void releaseWifiAndCpu() { + Log.d(TAG, "releaseWifiAndCpu() called"); + if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); + if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); + + wakeLock = null; + wifiLock = null; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java new file mode 100644 index 000000000..558f82b43 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -0,0 +1,106 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; + +import org.schabi.newpipe.R; + +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Formatter; +import java.util.Locale; + +public class PlayerHelper { + private PlayerHelper() {} + + private static final StringBuilder stringBuilder = new StringBuilder(); + private static final Formatter stringFormatter = new Formatter(stringBuilder, Locale.getDefault()); + private static final NumberFormat speedFormatter = new DecimalFormat("0.##x"); + private static final NumberFormat pitchFormatter = new DecimalFormat("##%"); + + //////////////////////////////////////////////////////////////////////////// + // Exposed helpers + //////////////////////////////////////////////////////////////////////////// + + public static String getTimeString(int milliSeconds) { + long seconds = (milliSeconds % 60000L) / 1000L; + long minutes = (milliSeconds % 3600000L) / 60000L; + long hours = (milliSeconds % 86400000L) / 3600000L; + long days = (milliSeconds % (86400000L * 7L)) / 86400000L; + + stringBuilder.setLength(0); + return days > 0 ? stringFormatter.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds).toString() + : hours > 0 ? stringFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() + : stringFormatter.format("%02d:%02d", minutes, seconds).toString(); + } + + public static String formatSpeed(float speed) { + return speedFormatter.format(speed); + } + + public static String formatPitch(float pitch) { + return pitchFormatter.format(pitch); + } + + public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { + return isResumeAfterAudioFocusGain(context, false); + } + + public static boolean isPlayerGestureEnabled(@NonNull final Context context) { + return isPlayerGestureEnabled(context, true); + } + + public static boolean isUsingOldPlayer(@NonNull final Context context) { + return isUsingOldPlayer(context, false); + } + + public static long getPreferredCacheSize(@NonNull final Context context) { + return 64 * 1024 * 1024L; + } + + public static long getPreferredFileSize(@NonNull final Context context) { + return 512 * 1024L; + } + + public static int getMinBufferMs(@NonNull final Context context) { + return 15000; + } + + public static int getMaxBufferMs(@NonNull final Context context) { + return 30000; + } + + public static long getBufferForPlaybackMs(@NonNull final Context context) { + return 2500L; + } + + public static long getBufferForPlaybackAfterRebufferMs(@NonNull final Context context) { + return 5000L; + } + + public static boolean isUsingDSP(@NonNull final Context context) { + return true; + } + //////////////////////////////////////////////////////////////////////////// + // Private helpers + //////////////////////////////////////////////////////////////////////////// + + @NonNull + private static SharedPreferences getPreferences(@NonNull final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context); + } + + private static boolean isResumeAfterAudioFocusGain(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), b); + } + + private static boolean isPlayerGestureEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.player_gesture_controls_key), b); + } + + private static boolean isUsingOldPlayer(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.use_old_player_key), b); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java new file mode 100644 index 000000000..b0990f56a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java @@ -0,0 +1,238 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.Allocator; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.io.IOException; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +/** + * DeferredMediaSource is specifically designed to allow external control over when + * the source metadata are loaded while being compatible with ExoPlayer's playlists. + * + * This media source follows the structure of how NewPipeExtractor's + * {@link org.schabi.newpipe.extractor.stream.StreamInfoItem} is converted into + * {@link org.schabi.newpipe.extractor.stream.StreamInfo}. Once conversion is complete, + * this media source behaves identically as any other native media sources. + * */ +public final class DeferredMediaSource implements MediaSource { + private final String TAG = "DeferredMediaSource@" + Integer.toHexString(hashCode()); + + /** + * This state indicates the {@link DeferredMediaSource} has just been initialized or reset. + * The source must be prepared and loaded again before playback. + * */ + public final static int STATE_INIT = 0; + /** + * This state indicates the {@link DeferredMediaSource} has been prepared and is ready to load. + * */ + public final static int STATE_PREPARED = 1; + /** + * This state indicates the {@link DeferredMediaSource} has been loaded without errors and + * is ready for playback. + * */ + public final static int STATE_LOADED = 2; + + public interface Callback { + /** + * Player-specific {@link com.google.android.exoplayer2.source.MediaSource} resolution + * from a given StreamInfo. + * */ + MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info); + } + + private PlayQueueItem stream; + private Callback callback; + private int state; + + private MediaSource mediaSource; + + /* Custom internal objects */ + private Disposable loader; + private ExoPlayer exoPlayer; + private Listener listener; + private Throwable error; + + public DeferredMediaSource(@NonNull final PlayQueueItem stream, + @NonNull final Callback callback) { + this.stream = stream; + this.callback = callback; + this.state = STATE_INIT; + } + + /** + * Returns the current state of the {@link DeferredMediaSource}. + * + * @see DeferredMediaSource#STATE_INIT + * @see DeferredMediaSource#STATE_PREPARED + * @see DeferredMediaSource#STATE_LOADED + * */ + public int state() { + return state; + } + + /** + * Parameters are kept in the class for delayed preparation. + * */ + @Override + public void prepareSource(ExoPlayer exoPlayer, boolean isTopLevelSource, Listener listener) { + this.exoPlayer = exoPlayer; + this.listener = listener; + this.state = STATE_PREPARED; + } + + /** + * Externally controlled loading. This method fully prepares the source to be used + * like any other native {@link com.google.android.exoplayer2.source.MediaSource}. + * + * Ideally, this should be called after this source has entered PREPARED state and + * called once only. + * + * If loading fails here, an error will be propagated out and result in an + * {@link com.google.android.exoplayer2.ExoPlaybackException ExoPlaybackException}, + * which is delegated to the player. + * */ + public synchronized void load() { + if (stream == null) { + Log.e(TAG, "Stream Info missing, media source loading terminated."); + return; + } + if (state != STATE_PREPARED || loader != null) return; + + Log.d(TAG, "Loading: [" + stream.getTitle() + "] with url: " + stream.getUrl()); + + final Function onReceive = new Function() { + @Override + public MediaSource apply(StreamInfo streamInfo) throws Exception { + return onStreamInfoReceived(stream, streamInfo); + } + }; + + final Consumer onSuccess = new Consumer() { + @Override + public void accept(MediaSource mediaSource) throws Exception { + onMediaSourceReceived(mediaSource); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(Throwable throwable) throws Exception { + onStreamInfoError(throwable); + } + }; + + loader = stream.getStream() + .observeOn(Schedulers.io()) + .map(onReceive) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onSuccess, onError); + } + + private MediaSource onStreamInfoReceived(@NonNull final PlayQueueItem item, + @NonNull final StreamInfo info) throws Exception { + if (callback == null) { + throw new Exception("No available callback for resolving stream info."); + } + + final MediaSource mediaSource = callback.sourceOf(item, info); + + if (mediaSource == null) { + throw new Exception("Unable to resolve source from stream info. URL: " + stream.getUrl() + + ", audio count: " + info.audio_streams.size() + + ", video count: " + info.video_only_streams.size() + info.video_streams.size()); + } + + return mediaSource; + } + + private void onMediaSourceReceived(final MediaSource mediaSource) throws Exception { + if (exoPlayer == null || listener == null || mediaSource == null) { + throw new Exception("MediaSource loading failed. URL: " + stream.getUrl()); + } + + Log.d(TAG, " Loaded: [" + stream.getTitle() + "] with url: " + stream.getUrl()); + state = STATE_LOADED; + + this.mediaSource = mediaSource; + this.mediaSource.prepareSource(exoPlayer, false, listener); + } + + private void onStreamInfoError(final Throwable throwable) { + Log.e(TAG, "Loading error:", throwable); + error = throwable; + state = STATE_LOADED; + } + + /** + * Delegate all errors to the player after {@link #load() load} is complete. + * + * Specifically, this method is called after an exception has occurred during loading or + * {@link com.google.android.exoplayer2.source.MediaSource#prepareSource(ExoPlayer, boolean, Listener) prepareSource}. + * */ + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (error != null) { + throw new IOException(error); + } + + if (mediaSource != null) { + mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId mediaPeriodId, Allocator allocator) { + return mediaSource.createPeriod(mediaPeriodId, allocator); + } + + /** + * Releases the media period (buffers). + * + * This may be called after {@link #releaseSource releaseSource}. + * */ + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + mediaSource.releasePeriod(mediaPeriod); + } + + /** + * Cleans up all internal custom objects creating during loading. + * + * This method is called when the parent {@link com.google.android.exoplayer2.source.MediaSource} + * is released or when the player is stopped. + * + * This method should not release or set null the resources passed in through the constructor. + * This method should not set null the internal {@link com.google.android.exoplayer2.source.MediaSource}. + * */ + @Override + public void releaseSource() { + if (mediaSource != null) { + mediaSource.releaseSource(); + } + if (loader != null) { + loader.dispose(); + } + + /* Do not set mediaSource as null here as it may be called through releasePeriod */ + loader = null; + exoPlayer = null; + listener = null; + error = null; + + state = STATE_INIT; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java new file mode 100644 index 000000000..8c9ff1440 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -0,0 +1,356 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.Nullable; +import android.util.Log; + +import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.events.MoveEvent; +import org.schabi.newpipe.playlist.events.PlayQueueEvent; +import org.schabi.newpipe.playlist.events.RemoveEvent; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.SerialDisposable; +import io.reactivex.functions.Consumer; +import io.reactivex.subjects.PublishSubject; + +public class MediaSourceManager { + private final String TAG = "MediaSourceManager@" + Integer.toHexString(hashCode()); + // One-side rolling window size for default loading + // Effectively loads windowSize * 2 + 1 streams, must be greater than 0 + private final int windowSize; + private final PlaybackListener playbackListener; + private final PlayQueue playQueue; + + // Process only the last load order when receiving a stream of load orders (lessens I/O) + // The higher it is, the less loading occurs during rapid noncritical timeline changes + // Not recommended to go below 100ms + private final long loadDebounceMillis; + private final PublishSubject loadSignal; + private final Disposable debouncedLoader; + + private final DeferredMediaSource.Callback sourceBuilder; + + private DynamicConcatenatingMediaSource sources; + + private Subscription playQueueReactor; + private SerialDisposable syncReactor; + + private boolean isBlocked; + + public MediaSourceManager(@NonNull final PlaybackListener listener, + @NonNull final PlayQueue playQueue) { + this(listener, playQueue, 1, 1000L); + } + + private MediaSourceManager(@NonNull final PlaybackListener listener, + @NonNull final PlayQueue playQueue, + final int windowSize, + final long loadDebounceMillis) { + if (windowSize <= 0) { + throw new UnsupportedOperationException("MediaSourceManager window size must be greater than 0"); + } + + this.playbackListener = listener; + this.playQueue = playQueue; + this.windowSize = windowSize; + this.loadDebounceMillis = loadDebounceMillis; + + this.syncReactor = new SerialDisposable(); + this.loadSignal = PublishSubject.create(); + this.debouncedLoader = getDebouncedLoader(); + + this.sourceBuilder = getSourceBuilder(); + + this.sources = new DynamicConcatenatingMediaSource(); + + playQueue.getBroadcastReceiver() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getReactor()); + } + + /*////////////////////////////////////////////////////////////////////////// + // DeferredMediaSource listener + //////////////////////////////////////////////////////////////////////////*/ + + private DeferredMediaSource.Callback getSourceBuilder() { + return new DeferredMediaSource.Callback() { + @Override + public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { + return playbackListener.sourceOf(item, info); + } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // Exposed Methods + //////////////////////////////////////////////////////////////////////////*/ + /** + * Dispose the manager and releases all message buses and loaders. + * */ + public void dispose() { + if (loadSignal != null) loadSignal.onComplete(); + if (debouncedLoader != null) debouncedLoader.dispose(); + if (playQueueReactor != null) playQueueReactor.cancel(); + if (syncReactor != null) syncReactor.dispose(); + if (sources != null) sources.releaseSource(); + + playQueueReactor = null; + syncReactor = null; + sources = null; + } + + /** + * Loads the current playing stream and the streams within its windowSize bound. + * + * Unblocks the player once the item at the current index is loaded. + * */ + public void load() { + loadSignal.onNext(System.currentTimeMillis()); + } + + /** + * Blocks the player and repopulate the sources. + * + * Does not ensure the player is unblocked and should be done explicitly through {@link #load() load}. + * */ + public void reset() { + tryBlock(); + populateSources(); + } + /*////////////////////////////////////////////////////////////////////////// + // Event Reactor + //////////////////////////////////////////////////////////////////////////*/ + + private Subscriber getReactor() { + return new Subscriber() { + @Override + public void onSubscribe(@NonNull Subscription d) { + if (playQueueReactor != null) playQueueReactor.cancel(); + playQueueReactor = d; + playQueueReactor.request(1); + } + + @Override + public void onNext(@NonNull PlayQueueEvent playQueueMessage) { + if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage); + } + + @Override + public void onError(@NonNull Throwable e) {} + + @Override + public void onComplete() {} + }; + } + + private void onPlayQueueChanged(final PlayQueueEvent event) { + if (playQueue.isEmpty()) { + playbackListener.shutdown(); + return; + } + + // why no pattern matching in Java =( + switch (event.type()) { + case INIT: + case REORDER: + case ERROR: + reset(); + break; + case APPEND: + populateSources(); + break; + case SELECT: + sync(); + break; + case REMOVE: + final RemoveEvent removeEvent = (RemoveEvent) event; + remove(removeEvent.getRemoveIndex()); + // Sync only when the currently playing is removed + if (removeEvent.getQueueIndex() == removeEvent.getRemoveIndex()) sync(); + break; + case MOVE: + final MoveEvent moveEvent = (MoveEvent) event; + move(moveEvent.getFromIndex(), moveEvent.getToIndex()); + break; + case RECOVERY: + default: + break; + } + + switch (event.type()) { + case INIT: + case REORDER: + case ERROR: + case APPEND: + loadInternal(); // low frequency, critical events + break; + case REMOVE: + case SELECT: + case MOVE: + case RECOVERY: + default: + load(); // high frequency or noncritical events + break; + } + + if (!isPlayQueueReady()) { + tryBlock(); + playQueue.fetch(); + } + if (playQueueReactor != null) playQueueReactor.request(1); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal Helpers + //////////////////////////////////////////////////////////////////////////*/ + + private boolean isPlayQueueReady() { + return playQueue.isComplete() || playQueue.size() - playQueue.getIndex() > windowSize; + } + + private boolean tryBlock() { + if (!isBlocked) { + playbackListener.block(); + resetSources(); + isBlocked = true; + return true; + } + return false; + } + + private boolean tryUnblock() { + if (isPlayQueueReady() && isBlocked && sources != null) { + isBlocked = false; + playbackListener.unblock(sources); + return true; + } + return false; + } + + private void sync() { + final PlayQueueItem currentItem = playQueue.getItem(); + if (currentItem == null) return; + + final Consumer syncPlayback = new Consumer() { + @Override + public void accept(StreamInfo streamInfo) throws Exception { + playbackListener.sync(currentItem, streamInfo); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(Throwable throwable) throws Exception { + Log.e(TAG, "Sync error:", throwable); + playbackListener.sync(currentItem,null); + } + }; + + syncReactor.set(currentItem.getStream().subscribe(syncPlayback, onError)); + } + + private void loadInternal() { + // The current item has higher priority + final int currentIndex = playQueue.getIndex(); + final PlayQueueItem currentItem = playQueue.getItem(currentIndex); + if (currentItem == null) return; + loadItem(currentItem); + + // The rest are just for seamless playback + final int leftBound = Math.max(0, currentIndex - windowSize); + final int rightLimit = currentIndex + windowSize + 1; + final int rightBound = Math.min(playQueue.size(), rightLimit); + final List items = new ArrayList<>(playQueue.getStreams().subList(leftBound, rightBound)); + + // Do a round robin + final int excess = rightLimit - playQueue.size(); + if (excess >= 0) items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + + for (final PlayQueueItem item: items) loadItem(item); + } + + private void loadItem(@Nullable final PlayQueueItem item) { + if (item == null) return; + + final int index = playQueue.indexOf(item); + if (index > sources.getSize() - 1) return; + + final DeferredMediaSource mediaSource = (DeferredMediaSource) sources.getMediaSource(playQueue.indexOf(item)); + if (mediaSource.state() == DeferredMediaSource.STATE_PREPARED) mediaSource.load(); + if (tryUnblock()) sync(); + } + + private void resetSources() { + if (this.sources != null) this.sources.releaseSource(); + this.sources = new DynamicConcatenatingMediaSource(); + } + + private void populateSources() { + if (sources == null) return; + + for (final PlayQueueItem item : playQueue.getStreams()) { + insert(playQueue.indexOf(item), new DeferredMediaSource(item, sourceBuilder)); + } + } + + private Disposable getDebouncedLoader() { + return loadSignal + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Consumer() { + @Override + public void accept(Long timestamp) throws Exception { + loadInternal(); + } + }); + } + /*////////////////////////////////////////////////////////////////////////// + // Media Source List Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Inserts a source into {@link DynamicConcatenatingMediaSource} with position + * in respect to the play queue. + * + * If the play queue index already exists, then the insert is ignored. + * */ + private void insert(final int queueIndex, final DeferredMediaSource source) { + if (sources == null) return; + if (queueIndex < 0 || queueIndex < sources.getSize()) return; + + sources.addMediaSource(queueIndex, source); + } + + /** + * Removes a source from {@link DynamicConcatenatingMediaSource} with the given play queue index. + * + * If the play queue index does not exist, the removal is ignored. + * */ + private void remove(final int queueIndex) { + if (sources == null) return; + if (queueIndex < 0 || queueIndex > sources.getSize()) return; + + sources.removeMediaSource(queueIndex); + } + + private void move(final int source, final int target) { + if (sources == null) return; + if (source < 0 || target < 0) return; + if (source >= sources.getSize() || target >= sources.getSize()) return; + + sources.moveMediaSource(source, target); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java new file mode 100644 index 000000000..226c643d5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java @@ -0,0 +1,56 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.util.List; + +public interface PlaybackListener { + /** + * Called when the stream at the current queue index is not ready yet. + * Signals to the listener to block the player from playing anything and notify the source + * is now invalid. + * + * May be called at any time. + * */ + void block(); + + /** + * Called when the stream at the current queue index is ready. + * Signals to the listener to resume the player by preparing a new source. + * + * May be called only when the player is blocked. + * */ + void unblock(final MediaSource mediaSource); + + /** + * Called when the queue index is refreshed. + * Signals to the listener to synchronize the player's window to the manager's + * window. + * + * May be called only after unblock is called. + * */ + void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info); + + /** + * Requests the listener to resolve a stream info into a media source + * according to the listener's implementation (background, popup or main video player). + * + * May be called at any time. + * */ + MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info); + + /** + * Called when the play queue can no longer to played or used. + * Currently, this means the play queue is empty and complete. + * Signals to the listener that it should shutdown. + * + * May be called at any time. + * */ + void shutdown(); +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/ExternalPlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/ExternalPlayQueue.java new file mode 100644 index 000000000..019b684d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/ExternalPlayQueue.java @@ -0,0 +1,104 @@ +package org.schabi.newpipe.playlist; + +import android.util.Log; + +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.SingleObserver; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public final class ExternalPlayQueue extends PlayQueue { + private final String TAG = "ExternalPlayQueue@" + Integer.toHexString(hashCode()); + + private boolean isComplete; + + private int serviceId; + private String baseUrl; + private String nextUrl; + + private transient Disposable fetchReactor; + + public ExternalPlayQueue(final int serviceId, + final String url, + final String nextPageUrl, + final List streams, + final int index) { + super(index, extractPlaylistItems(streams)); + + this.baseUrl = url; + this.nextUrl = nextPageUrl; + this.serviceId = serviceId; + + this.isComplete = nextPageUrl == null || nextPageUrl.isEmpty(); + } + + @Override + public boolean isComplete() { + return isComplete; + } + + @Override + public void fetch() { + ExtractorHelper.getMorePlaylistItems(this.serviceId, this.baseUrl, this.nextUrl) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistObserver()); + } + + private SingleObserver getPlaylistObserver() { + return new SingleObserver() { + @Override + public void onSubscribe(@NonNull Disposable d) { + if (isComplete || (fetchReactor != null && !fetchReactor.isDisposed())) { + d.dispose(); + } else { + fetchReactor = d; + } + } + + @Override + public void onSuccess(@NonNull ListExtractor.NextItemsResult result) { + if (!result.hasMoreStreams()) isComplete = true; + nextUrl = result.nextItemsUrl; + + append(extractPlaylistItems(result.nextItemsList)); + + fetchReactor.dispose(); + fetchReactor = null; + } + + @Override + public void onError(@NonNull Throwable e) { + Log.e(TAG, "Error fetching more playlist, marking playlist as complete.", e); + isComplete = true; + append(); // Notify change + } + }; + } + + @Override + public void dispose() { + super.dispose(); + if (fetchReactor != null) fetchReactor.dispose(); + } + + private static List extractPlaylistItems(final List infos) { + List result = new ArrayList<>(); + for (final InfoItem stream : infos) { + if (stream instanceof StreamInfoItem) { + result.add(new PlayQueueItem((StreamInfoItem) stream)); + } + } + return result; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java new file mode 100644 index 000000000..4d73e1cfd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java @@ -0,0 +1,427 @@ +package org.schabi.newpipe.playlist; + +import android.support.annotation.NonNull; +import android.util.Log; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.playlist.events.AppendEvent; +import org.schabi.newpipe.playlist.events.ErrorEvent; +import org.schabi.newpipe.playlist.events.InitEvent; +import org.schabi.newpipe.playlist.events.MoveEvent; +import org.schabi.newpipe.playlist.events.PlayQueueEvent; +import org.schabi.newpipe.playlist.events.RecoveryEvent; +import org.schabi.newpipe.playlist.events.RemoveEvent; +import org.schabi.newpipe.playlist.events.ReorderEvent; +import org.schabi.newpipe.playlist.events.SelectEvent; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.BackpressureStrategy; +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.subjects.BehaviorSubject; + +/** + * PlayQueue is responsible for keeping track of a list of streams and the index of + * the stream that should be currently playing. + * + * This class contains basic manipulation of a playlist while also functions as a + * message bus, providing all listeners with new updates to the play queue. + * + * This class can be serialized for passing intents, but in order to start the + * message bus, it must be initialized. + * */ +public abstract class PlayQueue implements Serializable { + private final String TAG = "PlayQueue@" + Integer.toHexString(hashCode()); + + public static final boolean DEBUG = true; + + private ArrayList backup; + private ArrayList streams; + private final AtomicInteger queueIndex; + + private transient BehaviorSubject eventBroadcast; + private transient Flowable broadcastReceiver; + private transient Subscription reportingReactor; + + PlayQueue(final int index, final List startWith) { + streams = new ArrayList<>(); + streams.addAll(startWith); + + queueIndex = new AtomicInteger(index); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist actions + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Initializes the play queue message buses. + * + * Also starts a self reporter for logging if debug mode is enabled. + * */ + public void init() { + eventBroadcast = BehaviorSubject.create(); + + broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) + .observeOn(AndroidSchedulers.mainThread()) + .startWith(new InitEvent()); + + if (DEBUG) broadcastReceiver.subscribe(getSelfReporter()); + } + + /** + * Dispose the play queue by stopping all message buses. + * */ + public void dispose() { + if (eventBroadcast != null) eventBroadcast.onComplete(); + if (reportingReactor != null) reportingReactor.cancel(); + + broadcastReceiver = null; + reportingReactor = null; + } + + /** + * Checks if the queue is complete. + * + * A queue is complete if it has loaded all items in an external playlist + * single stream or local queues are always complete. + * */ + public abstract boolean isComplete(); + + /** + * Load partial queue in the background, does nothing if the queue is complete. + * */ + public abstract void fetch(); + + /*////////////////////////////////////////////////////////////////////////// + // Readonly ops + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Returns the current index that should be played. + * */ + public int getIndex() { + return queueIndex.get(); + } + + /** + * Returns the current item that should be played. + * */ + public PlayQueueItem getItem() { + return getItem(getIndex()); + } + + /** + * Returns the item at the given index. + * May throw {@link IndexOutOfBoundsException}. + * */ + public PlayQueueItem getItem(int index) { + if (index >= streams.size() || streams.get(index) == null) return null; + return streams.get(index); + } + + /** + * Returns the index of the given item using referential equality. + * May be null despite play queue contains identical item. + * */ + public int indexOf(final PlayQueueItem item) { + // referential equality, can't think of a better way to do this + // todo: better than this + return streams.indexOf(item); + } + + /** + * Returns the current size of play queue. + * */ + public int size() { + return streams.size(); + } + + /** + * Checks if the play queue is empty. + * */ + public boolean isEmpty() { + return streams.isEmpty(); + } + + /** + * Determines if the current play queue is shuffled. + * */ + public boolean isShuffled() { + return backup != null; + } + + /** + * Returns an immutable view of the play queue. + * */ + @NonNull + public List getStreams() { + return Collections.unmodifiableList(streams); + } + + /** + * Returns the play queue's update broadcast. + * May be null if the play queue message bus is not initialized. + * */ + @NonNull + public Flowable getBroadcastReceiver() { + return broadcastReceiver; + } + + /*////////////////////////////////////////////////////////////////////////// + // Write ops + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Changes the current playing index to a new index. + * + * This method is guarded using in a circular manner for index exceeding the play queue size. + * + * Will emit a {@link SelectEvent} if the index is not the current playing index. + * */ + public synchronized void setIndex(final int index) { + final int oldIndex = getIndex(); + + int newIndex = index; + if (index < 0) newIndex = 0; + if (index >= streams.size()) newIndex = isComplete() ? index % streams.size() : streams.size() - 1; + + queueIndex.set(newIndex); + broadcast(new SelectEvent(oldIndex, newIndex)); + } + + /** + * Changes the current playing index by an offset amount. + * + * Will emit a {@link SelectEvent} if offset is non-zero. + * */ + public synchronized void offsetIndex(final int offset) { + setIndex(getIndex() + offset); + } + + /** + * Appends the given {@link PlayQueueItem}s to the current play queue. + * + * @see #append(List items) + * */ + public synchronized void append(final PlayQueueItem... items) { + append(Arrays.asList(items)); + } + + /** + * Appends the given {@link PlayQueueItem}s to the current play queue. + * + * If the play queue is shuffled, then append the items to the backup queue as is and + * append the shuffle items to the play queue. + * + * Will emit a {@link AppendEvent} on any given context. + * */ + public synchronized void append(final List items) { + List itemList = new ArrayList<>(items); + + if (isShuffled()) { + backup.addAll(itemList); + Collections.shuffle(itemList); + } + streams.addAll(itemList); + + broadcast(new AppendEvent(itemList.size())); + } + + /** + * Removes the item at the given index from the play queue. + * + * The current playing index will decrement if it is greater than the index being removed. + * On cases where the current playing index exceeds the playlist range, it is set to 0. + * + * Will emit a {@link RemoveEvent} if the index is within the play queue index range. + * */ + public synchronized void remove(final int index) { + if (index >= streams.size() || index < 0) return; + removeInternal(index); + broadcast(new RemoveEvent(index, getIndex())); + } + + /** + * Report an exception for the item at the current index in order and the course of action: + * if the error can be skipped or the current item should be removed. + * + * This is done as a separate event as the underlying manager may have + * different implementation regarding exceptions. + * */ + public synchronized void error(final boolean skippable) { + final int index = getIndex(); + + if (skippable) { + queueIndex.incrementAndGet(); + } else { + removeInternal(index); + } + + broadcast(new ErrorEvent(index, getIndex(), skippable)); + } + + private synchronized void removeInternal(final int removeIndex) { + final int currentIndex = queueIndex.get(); + final int size = size(); + + if (currentIndex > removeIndex) { + queueIndex.decrementAndGet(); + + } else if (currentIndex >= size) { + queueIndex.set(currentIndex % (size - 1)); + + } else if (currentIndex == removeIndex && currentIndex == size - 1){ + queueIndex.set(removeIndex - 1); + } + + if (backup != null) { + final int backupIndex = backup.indexOf(getItem(removeIndex)); + backup.remove(backupIndex); + } + streams.remove(removeIndex); + } + + /** + * Moves a queue item at the source index to the target index. + * + * If the item being moved is the currently playing, then the current playing index is set + * to that of the target. + * If the moved item is not the currently playing and moves to an index AFTER the + * current playing index, then the current playing index is decremented. + * Vice versa if the an item after the currently playing is moved BEFORE. + * */ + public synchronized void move(final int source, final int target) { + if (source < 0 || target < 0) return; + if (source >= streams.size() || target >= streams.size()) return; + + final int current = getIndex(); + if (source == current) { + queueIndex.set(target); + } else if (source < current && target >= current) { + queueIndex.decrementAndGet(); + } else if (source > current && target <= current) { + queueIndex.incrementAndGet(); + } + + streams.add(target, streams.remove(source)); + broadcast(new MoveEvent(source, target)); + } + + /** + * Sets the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * */ + public synchronized void setRecovery(final int index, final long position) { + if (index < 0 || index >= streams.size()) return; + + streams.get(index).setRecoveryPosition(position); + broadcast(new RecoveryEvent(index, position)); + } + + /** + * Revoke the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * */ + public synchronized void unsetRecovery(final int index) { + setRecovery(index, PlayQueueItem.RECOVERY_UNSET); + } + + /** + * Shuffles the current play queue. + * + * This method first backs up the existing play queue and item being played. + * Then a newly shuffled play queue will be generated along with currently + * playing item placed at the beginning of the queue. + * + * Will emit a {@link ReorderEvent} in any context. + * */ + public synchronized void shuffle() { + if (backup == null) { + backup = new ArrayList<>(streams); + } + final PlayQueueItem current = getItem(); + Collections.shuffle(streams); + + final int newIndex = streams.indexOf(current); + if (newIndex != -1) { + streams.add(0, streams.remove(newIndex)); + } + queueIndex.set(0); + + broadcast(new ReorderEvent()); + } + + /** + * Unshuffles the current play queue if a backup play queue exists. + * + * This method undoes shuffling and index will be set to the previously playing item if found, + * otherwise, the index will reset to 0. + * + * Will emit a {@link ReorderEvent} if a backup exists. + * */ + public synchronized void unshuffle() { + if (backup == null) return; + final PlayQueueItem current = getItem(); + + streams.clear(); + streams = backup; + backup = null; + + final int newIndex = streams.indexOf(current); + if (newIndex != -1) { + queueIndex.set(newIndex); + } else { + queueIndex.set(0); + } + + broadcast(new ReorderEvent()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Rx Broadcast + //////////////////////////////////////////////////////////////////////////*/ + + private void broadcast(final PlayQueueEvent event) { + if (eventBroadcast != null) { + eventBroadcast.onNext(event); + } + } + + private Subscriber getSelfReporter() { + return new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + if (reportingReactor != null) reportingReactor.cancel(); + reportingReactor = s; + reportingReactor.request(1); + } + + @Override + public void onNext(PlayQueueEvent event) { + Log.d(TAG, "Received broadcast: " + event.type().name() + ". Current index: " + getIndex() + ", play queue length: " + size() + "."); + reportingReactor.request(1); + } + + @Override + public void onError(Throwable t) { + Log.e(TAG, "Received broadcast error", t); + } + + @Override + public void onComplete() { + Log.d(TAG, "Broadcast is shutting down."); + } + }; + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java new file mode 100644 index 000000000..e16693ec6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java @@ -0,0 +1,204 @@ +package org.schabi.newpipe.playlist; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.playlist.events.AppendEvent; +import org.schabi.newpipe.playlist.events.ErrorEvent; +import org.schabi.newpipe.playlist.events.MoveEvent; +import org.schabi.newpipe.playlist.events.PlayQueueEvent; +import org.schabi.newpipe.playlist.events.RemoveEvent; +import org.schabi.newpipe.playlist.events.SelectEvent; + +import java.util.List; + +import io.reactivex.Observer; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; + +/** + * Created by Christian Schabesberger on 01.08.16. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoListAdapter.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class PlayQueueAdapter extends RecyclerView.Adapter { + private static final String TAG = PlayQueueAdapter.class.toString(); + + private static final int ITEM_VIEW_TYPE_ID = 0; + private static final int FOOTER_VIEW_TYPE_ID = 1; + + private final PlayQueueItemBuilder playQueueItemBuilder; + private final PlayQueue playQueue; + private boolean showFooter = false; + private View footer = null; + + private Disposable playQueueReactor; + + public class HFHolder extends RecyclerView.ViewHolder { + public HFHolder(View v) { + super(v); + view = v; + } + public View view; + } + + public PlayQueueAdapter(final Context context, final PlayQueue playQueue) { + this.playQueueItemBuilder = new PlayQueueItemBuilder(context); + this.playQueue = playQueue; + + startReactor(); + } + + public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) { + playQueueItemBuilder.setOnSelectedListener(listener); + } + + private void startReactor() { + final Observer observer = new Observer() { + @Override + public void onSubscribe(@NonNull Disposable d) { + if (playQueueReactor != null) playQueueReactor.dispose(); + playQueueReactor = d; + } + + @Override + public void onNext(@NonNull PlayQueueEvent playQueueMessage) { + if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage); + } + + @Override + public void onError(@NonNull Throwable e) {} + + @Override + public void onComplete() { + dispose(); + } + }; + + playQueue.getBroadcastReceiver().toObservable().subscribe(observer); + } + + private void onPlayQueueChanged(final PlayQueueEvent message) { + switch (message.type()) { + case RECOVERY: + // Do nothing. + break; + case SELECT: + final SelectEvent selectEvent = (SelectEvent) message; + notifyItemChanged(selectEvent.getOldIndex()); + notifyItemChanged(selectEvent.getNewIndex()); + break; + case APPEND: + final AppendEvent appendEvent = (AppendEvent) message; + notifyItemRangeInserted(playQueue.size(), appendEvent.getAmount()); + break; + case ERROR: + final ErrorEvent errorEvent = (ErrorEvent) message; + if (!errorEvent.isSkippable()) { + notifyItemRemoved(errorEvent.getErrorIndex()); + } + notifyItemChanged(errorEvent.getErrorIndex()); + notifyItemChanged(errorEvent.getQueueIndex()); + break; + case REMOVE: + final RemoveEvent removeEvent = (RemoveEvent) message; + notifyItemRemoved(removeEvent.getRemoveIndex()); + notifyItemChanged(removeEvent.getQueueIndex()); + break; + case MOVE: + final MoveEvent moveEvent = (MoveEvent) message; + notifyItemMoved(moveEvent.getFromIndex(), moveEvent.getToIndex()); + break; + case INIT: + case REORDER: + default: + notifyDataSetChanged(); + break; + } + } + + public void dispose() { + if (playQueueReactor != null) playQueueReactor.dispose(); + playQueueReactor = null; + } + + public void setFooter(View footer) { + this.footer = footer; + notifyItemChanged(playQueue.size()); + } + + public void showFooter(final boolean show) { + showFooter = show; + notifyItemChanged(playQueue.size()); + } + + public List getItems() { + return playQueue.getStreams(); + } + + @Override + public int getItemCount() { + int count = playQueue.getStreams().size(); + if(footer != null && showFooter) count++; + return count; + } + + @Override + public int getItemViewType(int position) { + if(footer != null && position == playQueue.getStreams().size() && showFooter) { + return FOOTER_VIEW_TYPE_ID; + } + + return ITEM_VIEW_TYPE_ID; + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int type) { + switch(type) { + case FOOTER_VIEW_TYPE_ID: + return new HFHolder(footer); + case ITEM_VIEW_TYPE_ID: + return new PlayQueueItemHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.play_queue_item, parent, false)); + default: + Log.e(TAG, "Attempting to create view holder with undefined type: " + type); + return null; + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + if(holder instanceof PlayQueueItemHolder) { + final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; + + // Build the list item + playQueueItemBuilder.buildStreamInfoItem(itemHolder, playQueue.getStreams().get(position)); + + // Check if the current item should be selected/highlighted + final boolean isSelected = playQueue.getIndex() == position; + itemHolder.itemSelected.setVisibility(isSelected ? View.VISIBLE : View.INVISIBLE); + itemHolder.itemView.setSelected(isSelected); + } else if(holder instanceof HFHolder && position == playQueue.getStreams().size() && footer != null && showFooter) { + ((HFHolder) holder).view = footer; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java new file mode 100644 index 000000000..969581f2f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java @@ -0,0 +1,117 @@ +package org.schabi.newpipe.playlist; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.io.Serializable; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Consumer; +import io.reactivex.schedulers.Schedulers; + +public class PlayQueueItem implements Serializable { + final public static long RECOVERY_UNSET = Long.MIN_VALUE; + + final private String title; + final private String url; + final private int serviceId; + final private long duration; + final private String thumbnailUrl; + final private String uploader; + + private long recoveryPosition; + private Throwable error; + + private transient Single stream; + + PlayQueueItem(@NonNull final StreamInfo info) { + this(info.name, info.url, info.service_id, info.duration, info.thumbnail_url, info.uploader_name); + this.stream = Single.just(info); + } + + PlayQueueItem(@NonNull final StreamInfoItem item) { + this(item.name, item.url, item.service_id, item.duration, item.thumbnail_url, item.uploader_name); + } + + private PlayQueueItem(final String name, final String url, final int serviceId, + final long duration, final String thumbnailUrl, final String uploader) { + this.title = name; + this.url = url; + this.serviceId = serviceId; + this.duration = duration; + this.thumbnailUrl = thumbnailUrl; + this.uploader = uploader; + + this.recoveryPosition = RECOVERY_UNSET; + } + + @NonNull + public String getTitle() { + return title; + } + + @NonNull + public String getUrl() { + return url; + } + + public int getServiceId() { + return serviceId; + } + + public long getDuration() { + return duration; + } + + @NonNull + public String getThumbnailUrl() { + return thumbnailUrl; + } + + @NonNull + public String getUploader() { + return uploader; + } + + public long getRecoveryPosition() { + return recoveryPosition; + } + + @Nullable + public Throwable getError() { + return error; + } + + @NonNull + public Single getStream() { + return stream == null ? stream = getInfo() : stream; + } + + @NonNull + private Single getInfo() { + final Consumer onError = new Consumer() { + @Override + public void accept(Throwable throwable) throws Exception { + error = throwable; + } + }; + + return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError(onError); + } + + //////////////////////////////////////////////////////////////////////////// + // Item States, keep external access out + //////////////////////////////////////////////////////////////////////////// + + /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { + this.recoveryPosition = recoveryPosition; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java new file mode 100644 index 000000000..c3649ce09 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java @@ -0,0 +1,112 @@ +package org.schabi.newpipe.playlist; + +import android.content.Context; +import android.graphics.Bitmap; +import android.text.TextUtils; +import android.view.MotionEvent; +import android.view.View; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import com.nostra13.universalimageloader.core.process.BitmapProcessor; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.Localization; + + +public class PlayQueueItemBuilder { + + private static final String TAG = PlayQueueItemBuilder.class.toString(); + + private final int thumbnailWidthPx; + private final int thumbnailHeightPx; + private final DisplayImageOptions imageOptions; + + public interface OnSelectedListener { + void selected(PlayQueueItem item, View view); + void held(PlayQueueItem item, View view); + void onStartDrag(PlayQueueItemHolder viewHolder); + } + + private OnSelectedListener onItemClickListener; + + public PlayQueueItemBuilder(final Context context) { + thumbnailWidthPx = context.getResources().getDimensionPixelSize(R.dimen.play_queue_thumbnail_width); + thumbnailHeightPx = context.getResources().getDimensionPixelSize(R.dimen.play_queue_thumbnail_height); + imageOptions = buildImageOptions(thumbnailWidthPx, thumbnailHeightPx); + } + + public void setOnSelectedListener(OnSelectedListener listener) { + this.onItemClickListener = listener; + } + + public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueueItem item) { + if (!TextUtils.isEmpty(item.getTitle())) holder.itemVideoTitleView.setText(item.getTitle()); + if (!TextUtils.isEmpty(item.getUploader())) holder.itemAdditionalDetailsView.setText(item.getUploader()); + + if (item.getDuration() > 0) { + holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); + } else { + holder.itemDurationView.setVisibility(View.GONE); + } + + ImageLoader.getInstance().displayImage(item.getThumbnailUrl(), holder.itemThumbnailView, imageOptions); + + holder.itemRoot.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (onItemClickListener != null) { + onItemClickListener.selected(item, view); + } + } + }); + + holder.itemRoot.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + if (onItemClickListener != null) { + onItemClickListener.held(item, view); + return true; + } + return false; + } + }); + + holder.itemThumbnailView.setOnTouchListener(getOnTouchListener(holder)); + holder.itemHandle.setOnTouchListener(getOnTouchListener(holder)); + } + + private View.OnTouchListener getOnTouchListener(final PlayQueueItemHolder holder) { + return new View.OnTouchListener() { + @Override + public boolean onTouch(View view, MotionEvent motionEvent) { + view.performClick(); + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + onItemClickListener.onStartDrag(holder); + } + return false; + } + }; + } + + private DisplayImageOptions buildImageOptions(final int widthPx, final int heightPx) { + final BitmapProcessor bitmapProcessor = new BitmapProcessor() { + @Override + public Bitmap process(Bitmap bitmap) { + final Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, widthPx, heightPx, false); + bitmap.recycle(); + return resizedBitmap; + } + }; + + return new DisplayImageOptions.Builder() + .showImageOnFail(R.drawable.dummy_thumbnail) + .showImageForEmptyUri(R.drawable.dummy_thumbnail) + .showImageOnLoading(R.drawable.dummy_thumbnail) + .bitmapConfig(Bitmap.Config.RGB_565) // Users won't be able to see much anyways + .preProcessor(bitmapProcessor) + .imageScaleType(ImageScaleType.EXACTLY) + .build(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemHolder.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemHolder.java new file mode 100644 index 000000000..3837c4046 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemHolder.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.playlist; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.info_list.holder.InfoItemHolder; + +/** + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * StreamInfoItemHolder.java is part of NewPipe. + *

+ * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

+ * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class PlayQueueItemHolder extends RecyclerView.ViewHolder { + + public final TextView itemVideoTitleView, itemDurationView, itemAdditionalDetailsView; + public final ImageView itemSelected, itemThumbnailView, itemHandle; + + public final View itemRoot; + + public PlayQueueItemHolder(View v) { + super(v); + itemRoot = v.findViewById(R.id.itemRoot); + itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); + itemDurationView = v.findViewById(R.id.itemDurationView); + itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); + itemSelected = v.findViewById(R.id.itemSelected); + itemThumbnailView = v.findViewById(R.id.itemThumbnailView); + itemHandle = v.findViewById(R.id.itemHandle); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java new file mode 100644 index 000000000..fc68e931a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java @@ -0,0 +1,19 @@ +package org.schabi.newpipe.playlist; + +import org.schabi.newpipe.extractor.stream.StreamInfo; + +import java.util.Collections; + +public final class SinglePlayQueue extends PlayQueue { + public SinglePlayQueue(final StreamInfo info) { + super(0, Collections.singletonList(new PlayQueueItem(info))); + } + + @Override + public boolean isComplete() { + return true; + } + + @Override + public void fetch() {} +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/AppendEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/AppendEvent.java new file mode 100644 index 000000000..b3ba8835a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/AppendEvent.java @@ -0,0 +1,19 @@ +package org.schabi.newpipe.playlist.events; + + +public class AppendEvent implements PlayQueueEvent { + final private int amount; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.APPEND; + } + + public AppendEvent(final int amount) { + this.amount = amount; + } + + public int getAmount() { + return amount; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/ErrorEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/ErrorEvent.java new file mode 100644 index 000000000..45629feb6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/ErrorEvent.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.playlist.events; + + +public class ErrorEvent implements PlayQueueEvent { + final private int errorIndex; + final private int queueIndex; + final private boolean skippable; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.ERROR; + } + + public ErrorEvent(final int errorIndex, final int queueIndex, final boolean skippable) { + this.errorIndex = errorIndex; + this.queueIndex = queueIndex; + this.skippable = skippable; + } + + public int getErrorIndex() { + return errorIndex; + } + + public int getQueueIndex() { + return queueIndex; + } + + public boolean isSkippable() { + return skippable; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/InitEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/InitEvent.java new file mode 100644 index 000000000..1c1d01508 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/InitEvent.java @@ -0,0 +1,8 @@ +package org.schabi.newpipe.playlist.events; + +public class InitEvent implements PlayQueueEvent { + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.INIT; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/MoveEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/MoveEvent.java new file mode 100644 index 000000000..4370fe328 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/MoveEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipe.playlist.events; + +public class MoveEvent implements PlayQueueEvent { + final private int fromIndex; + final private int toIndex; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.MOVE; + } + + public MoveEvent(final int oldIndex, final int newIndex) { + this.fromIndex = oldIndex; + this.toIndex = newIndex; + } + + public int getFromIndex() { + return fromIndex; + } + + public int getToIndex() { + return toIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEvent.java new file mode 100644 index 000000000..c56c3fbc0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEvent.java @@ -0,0 +1,7 @@ +package org.schabi.newpipe.playlist.events; + +import java.io.Serializable; + +public interface PlayQueueEvent extends Serializable { + PlayQueueEventType type(); +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEventType.java b/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEventType.java new file mode 100644 index 000000000..0fc40c098 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/PlayQueueEventType.java @@ -0,0 +1,27 @@ +package org.schabi.newpipe.playlist.events; + +public enum PlayQueueEventType { + INIT, + + // sent when the index is changed + SELECT, + + // sent when more streams are added to the play queue + APPEND, + + // sent when a pending stream is removed from the play queue + REMOVE, + + // sent when two streams swap place in the play queue + MOVE, + + // sent when queue is shuffled + REORDER, + + // sent when recovery record is set on a stream + RECOVERY, + + // sent when the item at index has caused an exception + ERROR +} + diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/RecoveryEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/RecoveryEvent.java new file mode 100644 index 000000000..715cf88c4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/RecoveryEvent.java @@ -0,0 +1,25 @@ +package org.schabi.newpipe.playlist.events; + + +public class RecoveryEvent implements PlayQueueEvent { + final private int index; + final private long position; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.RECOVERY; + } + + public RecoveryEvent(final int index, final long position) { + this.index = index; + this.position = position; + } + + public int getIndex() { + return index; + } + + public long getPosition() { + return position; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/RemoveEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/RemoveEvent.java new file mode 100644 index 000000000..464dbfa49 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/RemoveEvent.java @@ -0,0 +1,25 @@ +package org.schabi.newpipe.playlist.events; + + +public class RemoveEvent implements PlayQueueEvent { + final private int removeIndex; + final private int queueIndex; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.REMOVE; + } + + public RemoveEvent(final int removeIndex, final int queueIndex) { + this.removeIndex = removeIndex; + this.queueIndex = queueIndex; + } + + public int getQueueIndex() { + return queueIndex; + } + + public int getRemoveIndex() { + return removeIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java new file mode 100644 index 000000000..f1d09d457 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java @@ -0,0 +1,12 @@ +package org.schabi.newpipe.playlist.events; + +public class ReorderEvent implements PlayQueueEvent { + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.REORDER; + } + + public ReorderEvent() { + + } +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/SelectEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/SelectEvent.java new file mode 100644 index 000000000..d1d0b1137 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/SelectEvent.java @@ -0,0 +1,25 @@ +package org.schabi.newpipe.playlist.events; + + +public class SelectEvent implements PlayQueueEvent { + final private int oldIndex; + final private int newIndex; + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.SELECT; + } + + public SelectEvent(final int oldIndex, final int newIndex) { + this.oldIndex = oldIndex; + this.newIndex = newIndex; + } + + public int getOldIndex() { + return oldIndex; + } + + public int getNewIndex() { + return newIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 3fda47438..697a0c7c1 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -56,6 +56,13 @@ public final class ListHelper { if (defaultPreferences == null) return 0; String defaultResolution = defaultPreferences.getString(context.getString(R.string.default_resolution_key), context.getString(R.string.default_resolution_value)); + return getDefaultResolutionIndex(context, videoStreams, defaultResolution); + } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + public static int getDefaultResolutionIndex(Context context, List videoStreams, String defaultResolution) { return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } @@ -67,6 +74,13 @@ public final class ListHelper { if (defaultPreferences == null) return 0; String defaultResolution = defaultPreferences.getString(context.getString(R.string.default_popup_resolution_key), context.getString(R.string.default_popup_resolution_value)); + return getPopupDefaultResolutionIndex(context, videoStreams, defaultResolution); + } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + public static int getPopupDefaultResolutionIndex(Context context, List videoStreams, String defaultResolution) { return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index b6ec3cd3a..43ebc1677 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -6,6 +6,7 @@ import android.content.res.Resources; import android.preference.PreferenceManager; import android.support.annotation.PluralsRes; import android.support.annotation.StringRes; +import android.text.TextUtils; import org.schabi.newpipe.R; diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index b08251436..a68494706 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,11 +1,14 @@ package org.schabi.newpipe.util; import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.preference.PreferenceManager; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; +import android.support.v7.app.AppCompatActivity; import com.nostra13.universalimageloader.core.ImageLoader; @@ -17,8 +20,6 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; @@ -27,13 +28,11 @@ import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.history.HistoryActivity; -import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.BasePlayer; import org.schabi.newpipe.player.VideoPlayer; +import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.settings.SettingsActivity; -import java.util.ArrayList; - @SuppressWarnings({"unused", "WeakerAccess"}) public class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -41,46 +40,41 @@ public class NavigationHelper { /*////////////////////////////////////////////////////////////////////////// // Players //////////////////////////////////////////////////////////////////////////*/ + public static Intent getPlayerIntent(final Context context, + final Class targetClazz, + final PlayQueue playQueue, + final String quality) { + Intent intent = new Intent(context, targetClazz) + .putExtra(VideoPlayer.PLAY_QUEUE, playQueue); + if (quality != null) intent.putExtra(VideoPlayer.PLAYBACK_QUALITY, quality); - public static Intent getOpenVideoPlayerIntent(Context context, Class targetClazz, StreamInfo info, int selectedStreamIndex) { - Intent mIntent = new Intent(context, targetClazz) - .putExtra(BasePlayer.VIDEO_TITLE, info.name) - .putExtra(BasePlayer.VIDEO_URL, info.url) - .putExtra(BasePlayer.VIDEO_THUMBNAIL_URL, info.thumbnail_url) - .putExtra(BasePlayer.CHANNEL_NAME, info.uploader_name) - .putExtra(VideoPlayer.INDEX_SEL_VIDEO_STREAM, selectedStreamIndex) - .putExtra(VideoPlayer.VIDEO_STREAMS_LIST, new ArrayList<>(ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false))) - .putExtra(VideoPlayer.VIDEO_ONLY_AUDIO_STREAM, ListHelper.getHighestQualityAudio(info.audio_streams)); - if (info.start_position > 0) mIntent.putExtra(BasePlayer.START_POSITION, info.start_position * 1000L); - return mIntent; + return intent; } - public static Intent getOpenVideoPlayerIntent(Context context, Class targetClazz, VideoPlayer instance) { - return new Intent(context, targetClazz) - .putExtra(BasePlayer.VIDEO_TITLE, instance.getVideoTitle()) - .putExtra(BasePlayer.VIDEO_URL, instance.getVideoUrl()) - .putExtra(BasePlayer.VIDEO_THUMBNAIL_URL, instance.getVideoThumbnailUrl()) - .putExtra(BasePlayer.CHANNEL_NAME, instance.getUploaderName()) - .putExtra(VideoPlayer.INDEX_SEL_VIDEO_STREAM, instance.getSelectedStreamIndex()) - .putExtra(VideoPlayer.VIDEO_STREAMS_LIST, instance.getVideoStreamsList()) - .putExtra(VideoPlayer.VIDEO_ONLY_AUDIO_STREAM, instance.getAudioStream()) - .putExtra(BasePlayer.START_POSITION, instance.getPlayer().getCurrentPosition()) - .putExtra(BasePlayer.PLAYBACK_SPEED, instance.getPlaybackSpeed()); + public static Intent getPlayerIntent(final Context context, + final Class targetClazz, + final PlayQueue playQueue) { + return getPlayerIntent(context, targetClazz, playQueue, null); } - public static Intent getOpenBackgroundPlayerIntent(Context context, StreamInfo info) { - return getOpenBackgroundPlayerIntent(context, info, info.audio_streams.get(ListHelper.getDefaultAudioFormat(context, info.audio_streams))); + public static Intent getPlayerEnqueueIntent(final Context context, + final Class targetClazz, + final PlayQueue playQueue) { + return getPlayerIntent(context, targetClazz, playQueue) + .putExtra(BasePlayer.APPEND_ONLY, true); } - public static Intent getOpenBackgroundPlayerIntent(Context context, StreamInfo info, AudioStream audioStream) { - Intent mIntent = new Intent(context, BackgroundPlayer.class) - .putExtra(BasePlayer.VIDEO_TITLE, info.name) - .putExtra(BasePlayer.VIDEO_URL, info.url) - .putExtra(BasePlayer.VIDEO_THUMBNAIL_URL, info.thumbnail_url) - .putExtra(BasePlayer.CHANNEL_NAME, info.uploader_name) - .putExtra(BackgroundPlayer.AUDIO_STREAM, audioStream); - if (info.start_position > 0) mIntent.putExtra(BasePlayer.START_POSITION, info.start_position * 1000L); - return mIntent; + public static Intent getPlayerIntent(final Context context, + final Class targetClazz, + final PlayQueue playQueue, + final int repeatMode, + final float playbackSpeed, + final float playbackPitch, + final String playbackQuality) { + return getPlayerIntent(context, targetClazz, playQueue, playbackQuality) + .putExtra(BasePlayer.REPEAT_MODE, repeatMode) + .putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed) + .putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch); } /*////////////////////////////////////////////////////////////////////////// @@ -303,4 +297,55 @@ public class NavigationHelper { } return null; } + + + private static Uri openMarketUrl(String packageName) { + return Uri.parse("market://details") + .buildUpon() + .appendQueryParameter("id", packageName) + .build(); + } + + private static Uri getGooglePlayUrl(String packageName) { + return Uri.parse("https://play.google.com/store/apps/details") + .buildUpon() + .appendQueryParameter("id", packageName) + .build(); + } + + private static void installApp(Context context, String packageName) { + try { + // Try market:// scheme + context.startActivity(new Intent(Intent.ACTION_VIEW, openMarketUrl(packageName))); + } catch (ActivityNotFoundException e) { + // Fall back to google play URL (don't worry F-Droid can handle it :) + context.startActivity(new Intent(Intent.ACTION_VIEW, getGooglePlayUrl(packageName))); + } + } + + /** + * Start an activity to install Kore + * @param context the context + */ + public static void installKore(Context context) { + installApp(context, context.getString(R.string.kore_package)); + } + + /** + * Start Kore app to show a video on Kodi + * + * For a list of supported urls see the + * + * Kore source code + * . + * + * @param context the context to use + * @param videoURL the url to the video + */ + public static void playWithKore(Context context, Uri videoURL) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setPackage(context.getString(R.string.kore_package)); + intent.setData(videoURL); + context.startActivity(intent); + } } diff --git a/app/src/main/res/drawable-hdpi/ic_drag_handle_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_drag_handle_black_24dp.png new file mode 100644 index 000000000..7ebc39358 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_drag_handle_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_drag_handle_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_drag_handle_white_24dp.png new file mode 100644 index 000000000..8747b9ecb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_drag_handle_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_black_24dp.png new file mode 100644 index 000000000..459eec3fe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_white_24dp.png new file mode 100644 index 000000000..2c476010b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_fiber_manual_record_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_shuffle_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_shuffle_white_24dp.png new file mode 100644 index 000000000..ab55a83f4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_shuffle_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_drag_handle_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_drag_handle_black_24dp.png new file mode 100644 index 000000000..e09d492fc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_drag_handle_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_drag_handle_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_drag_handle_white_24dp.png new file mode 100644 index 000000000..e509264d3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_drag_handle_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_black_24dp.png new file mode 100644 index 000000000..cfc8b4e60 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_white_24dp.png new file mode 100644 index 000000000..f6f53a154 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_fiber_manual_record_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_shuffle_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_shuffle_white_24dp.png new file mode 100644 index 000000000..d13a258a3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_shuffle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_drag_handle_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_drag_handle_black_24dp.png new file mode 100644 index 000000000..906f5eee0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_drag_handle_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_drag_handle_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_drag_handle_white_24dp.png new file mode 100644 index 000000000..aa1547b04 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_drag_handle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_black_24dp.png new file mode 100644 index 000000000..3eb79e4c1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_white_24dp.png new file mode 100644 index 000000000..0fa16b016 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_fiber_manual_record_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_shuffle_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_shuffle_white_24dp.png new file mode 100644 index 000000000..66c15ce62 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_shuffle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drag_handle_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_drag_handle_black_24dp.png new file mode 100644 index 000000000..71da19a59 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drag_handle_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drag_handle_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_drag_handle_white_24dp.png new file mode 100644 index 000000000..e91ef07e9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drag_handle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_black_24dp.png new file mode 100644 index 000000000..b53beb106 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_white_24dp.png new file mode 100644 index 000000000..422487473 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_fiber_manual_record_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_shuffle_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_shuffle_white_24dp.png new file mode 100644 index 000000000..dc8e5341b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_shuffle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_black_24dp.png new file mode 100644 index 000000000..d102adeb2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_white_24dp.png new file mode 100644 index 000000000..122690738 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_drag_handle_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_black_24dp.png new file mode 100644 index 000000000..eff1e3594 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_white_24dp.png new file mode 100644 index 000000000..591b54a57 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_fiber_manual_record_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shuffle_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_shuffle_white_24dp.png new file mode 100644 index 000000000..e24dfa3b0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_shuffle_white_24dp.png differ diff --git a/app/src/main/res/drawable/dark_selector.xml b/app/src/main/res/drawable/dark_selector.xml new file mode 100644 index 000000000..eb658e16d --- /dev/null +++ b/app/src/main/res/drawable/dark_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/light_selector.xml b/app/src/main/res/drawable/light_selector.xml new file mode 100644 index 000000000..63f2ccaf3 --- /dev/null +++ b/app/src/main/res/drawable/light_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml new file mode 100644 index 000000000..a577b7fe0 --- /dev/null +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -0,0 +1,300 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_history.xml b/app/src/main/res/layout/activity_history.xml index 7076eb34f..e53b9bff9 100644 --- a/app/src/main/res/layout/activity_history.xml +++ b/app/src/main/res/layout/activity_history.xml @@ -13,7 +13,6 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingTop="@dimen/appbar_padding_top" android:theme="@style/ThemeOverlay.AppCompat.ActionBar" app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar"> diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index e3ef022f9..5c6349c35 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -1,9 +1,10 @@ - @@ -11,6 +12,7 @@ android:id="@+id/aspectRatioLayout" android:layout_width="match_parent" android:layout_height="match_parent" + android:layout_centerInParent="true" android:layout_gravity="center"> + + + + + + + + + + + + + + + + @@ -94,10 +178,15 @@ android:id="@+id/channelTextView" android:layout_width="match_parent" android:layout_height="wrap_content" - android:ellipsize="end" - android:maxLines="1" + android:ellipsize="marquee" + android:fadingEdge="horizontal" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" android:textColor="@android:color/white" android:textSize="12sp" + android:clickable="true" + android:focusable="true" tools:text="The Video Artist LONG very LONG very Long"/> @@ -135,26 +224,28 @@ android:layout_height="35dp" android:layout_marginLeft="2dp" android:layout_marginRight="2dp" - android:layout_toLeftOf="@+id/repeatButton" + android:layout_toLeftOf="@+id/queueButton" android:background="#00ffffff" android:clickable="true" + android:focusable="true" android:padding="8dp" android:scaleType="fitXY" android:src="@drawable/ic_screen_rotation_white" tools:ignore="ContentDescription,RtlHardcoded"/> @@ -223,11 +315,45 @@ android:layout_width="100dp" android:layout_height="100dp" android:layout_centerInParent="true" - android:background="#00000000" + android:clickable="true" + android:focusable="true" + android:background="?attr/selectableItemBackgroundBorderless" android:scaleType="fitXY" android:src="@drawable/ic_pause_white" tools:ignore="ContentDescription"/> + + + + @@ -328,4 +454,4 @@ tools:visibility="visible"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player_queue_control.xml b/app/src/main/res/layout/activity_player_queue_control.xml new file mode 100644 index 000000000..a59e5ba2e --- /dev/null +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index b067db7bc..86e8d6ad9 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -1,308 +1,368 @@ - - - + + + android:visibility="visible" + app:parallax_factor="1.9"> - + + android:layout_height="wrap_content" + android:orientation="vertical"> - - + + android:background="@android:color/black" + android:clickable="true" + android:focusable="true" + android:foreground="?attr/selectableItemBackground"> - - + + + + + + + + + + + + android:focusable="true" + android:paddingLeft="12dp" + android:paddingRight="12dp"> - + tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a ultricies ex. Integer sit amet sodales risus. Duis non mi et urna pretium bibendum. Nunc eleifend est quis ipsum porttitor egestas. Sed facilisis, nisl quis eleifend pellentesque, orci metus egestas dolor, at accumsan eros metus quis libero."/> + android:id="@+id/detail_toggle_description_view" + android:layout_width="15dp" + android:layout_height="15dp" + android:layout_gravity="center_vertical|right" + android:layout_marginLeft="5dp" + android:src="@drawable/arrow_down" + tools:ignore="ContentDescription,RtlHardcoded"/> - - + + + + + + + + android:layout_below="@+id/detail_title_root_layout" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> - - + + + + + + + + + + + + + + + + + + + + + + + + android:paddingRight="12dp" + android:paddingTop="8dp"> + + + + tools:text="Uploader"/> - + android:layout_marginRight="12dp" + android:text="@string/rss_button_title" + android:textSize="12sp" + android:theme="@style/RedButton" + android:drawableLeft="@drawable/ic_rss_feed_white_24dp" + tools:ignore="RtlHardcoded" + android:visibility="gone"/>--> + - - - - + android:layout_height="1px" + android:layout_marginLeft="8dp" + android:layout_marginRight="8dp" + android:background="?attr/separator_color"/> - - - - + - - + android:textAppearance="?android:attr/textAppearanceMedium" + android:textSize="@dimen/video_item_detail_upload_date_text_size" + android:textStyle="bold" + tools:text="Published on Oct 2, 2009"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginBottom="8dp" + android:layout_marginLeft="12dp" + android:layout_marginRight="12dp" + android:layout_marginTop="3dp" + android:textAppearance="?android:attr/textAppearanceMedium" + android:textIsSelectable="true" + android:textSize="@dimen/video_item_detail_description_text_size" + tools:text="Description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a ultricies ex. Integer sit amet sodales risus. Duis non mi et urna pretium bibendum."/> - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + + + + + + + + + diff --git a/app/src/main/res/layout/play_queue_item.xml b/app/src/main/res/layout/play_queue_item.xml new file mode 100644 index 000000000..4d5a6fbd4 --- /dev/null +++ b/app/src/main/res/layout/play_queue_item.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/player_notification.xml b/app/src/main/res/layout/player_notification.xml index 958b9bf3d..157615bb7 100644 --- a/app/src/main/res/layout/player_notification.xml +++ b/app/src/main/res/layout/player_notification.xml @@ -12,6 +12,7 @@ android:layout_height="64dp" android:background="@color/background_notification_color" android:clickable="true" + android:focusable="true" android:gravity="center_vertical" android:orientation="horizontal"> @@ -58,6 +59,7 @@ android:layout_height="match_parent" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="5dp" android:scaleType="fitCenter" android:src="@drawable/ic_repeat_white" @@ -69,9 +71,10 @@ android:layout_height="match_parent" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="5dp" android:scaleType="fitCenter" - android:src="@drawable/ic_action_av_fast_rewind" + android:src="@drawable/exo_controls_previous" tools:ignore="ContentDescription"/> @@ -89,9 +93,10 @@ android:layout_height="match_parent" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="5dp" android:scaleType="fitCenter" - android:src="@drawable/ic_action_av_fast_forward" + android:src="@drawable/exo_controls_next" tools:ignore="ContentDescription"/> @@ -26,6 +27,7 @@ android:layout_alignParentRight="true" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="8dp" android:scaleType="fitCenter" android:src="@drawable/ic_close_white_24dp" @@ -82,9 +84,11 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" android:layout_marginTop="2dp" android:layout_alignTop="@+id/notificationProgressBar" android:layout_toRightOf="@+id/notificationCover" + android:layout_toEndOf="@+id/notificationCover" android:ellipsize="end" android:maxLines="1" android:textSize="12sp" @@ -109,6 +113,7 @@ android:layout_centerVertical="true" android:background="#00000000" android:clickable="true" + android:focusable="true" android:scaleType="fitXY" android:src="@drawable/ic_repeat_white" tools:ignore="ContentDescription"/> @@ -122,9 +127,10 @@ android:layout_toLeftOf="@+id/notificationPlayPause" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="2dp" android:scaleType="fitCenter" - android:src="@drawable/ic_action_av_fast_rewind" + android:src="@drawable/exo_controls_previous" tools:ignore="ContentDescription"/> @@ -150,107 +157,10 @@ android:layout_marginRight="8dp" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="2dp" android:scaleType="fitCenter" - android:src="@drawable/ic_action_av_fast_forward" + android:src="@drawable/exo_controls_next" tools:ignore="ContentDescription"/> - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/player_popup_notification.xml b/app/src/main/res/layout/player_popup_notification.xml index 4010064d9..ab436a1fa 100644 --- a/app/src/main/res/layout/player_popup_notification.xml +++ b/app/src/main/res/layout/player_popup_notification.xml @@ -7,6 +7,7 @@ android:layout_height="64dp" android:background="@color/background_notification_color" android:clickable="true" + android:focusable="true" android:gravity="center_vertical" android:orientation="horizontal"> @@ -54,6 +55,7 @@ android:layout_height="match_parent" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="5dp" android:scaleType="fitCenter" android:src="@drawable/ic_repeat_white" @@ -65,6 +67,7 @@ android:layout_height="match_parent" android:background="#00000000" android:clickable="true" + android:focusable="true" android:src="@drawable/ic_pause_white" tools:ignore="ContentDescription"/> @@ -75,6 +78,7 @@ android:layout_marginLeft="5dp" android:background="#00000000" android:clickable="true" + android:focusable="true" android:padding="5dp" android:scaleType="fitCenter" android:src="@drawable/ic_close_white_24dp" diff --git a/app/src/main/res/layout/playlist_header.xml b/app/src/main/res/layout/playlist_header.xml index 192363359..0f129672d 100644 --- a/app/src/main/res/layout/playlist_header.xml +++ b/app/src/main/res/layout/playlist_header.xml @@ -20,62 +20,125 @@ android:maxLines="2" android:textAppearance="?android:attr/textAppearanceLarge" android:textSize="@dimen/playlist_detail_title_text_size" - tools:text="Mix musics #23 title Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum"/> + tools:text="Mix musics #23 title Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum" /> + android:id="@+id/playlist_meta"> + - + + + + + tools:text="234 videos"/> + - + android:id="@+id/play_control" + android:paddingLeft="5dp" + android:paddingRight="5dp" + android:layout_below="@+id/playlist_meta"> + +