From b088c109a5b8a3347ecb5ee37baccced17032fba Mon Sep 17 00:00:00 2001 From: polymorphicshade Date: Sat, 20 Jan 2024 17:55:31 -0700 Subject: [PATCH] implemented SponsorBlock --- .../7.json | 72 ++-- .../org/schabi/newpipe/RouterActivity.java | 9 +- .../schabi/newpipe/database/AppDatabase.java | 6 +- .../dao/SponsorBlockWhitelistDAO.java | 35 ++ .../dao/SponsorBlockWhitelistEntry.java | 33 ++ .../fragments/detail/VideoDetailFragment.java | 254 ++++++++++++- .../sponsorblock/SponsorBlockFragment.java | 333 ++++++++++++++++++ .../SponsorBlockFragmentListener.java | 11 + .../SponsorBlockSegmentListAdapter.java | 294 ++++++++++++++++ ...ponsorBlockSegmentListAdapterListener.java | 5 + .../local/history/HistoryRecordManager.java | 5 +- .../sponsorblock/SponsorBlockDataManager.java | 42 +++ .../newpipe/player/PlayQueueActivity.java | 10 +- .../org/schabi/newpipe/player/Player.java | 121 ++++++- .../schabi/newpipe/player/PlayerListener.java | 5 + .../player/playback/MediaSourceManager.java | 24 +- .../player/playqueue/PlayQueueItem.java | 6 +- .../schabi/newpipe/player/ui/PlayerUi.java | 3 + .../newpipe/player/ui/VideoPlayerUi.java | 6 + .../newpipe/settings/NewPipeSettings.java | 2 + .../settings/SettingsResourceRegistry.java | 2 + ...ponsorBlockCategoriesSettingsFragment.java | 154 ++++++++ .../SponsorBlockSettingsFragment.java | 86 +++++ .../settings/custom/EditColorPreference.java | 81 +++++ .../custom/SponsorBlockApiUrlPreference.java | 104 ++++++ .../schabi/newpipe/util/ExtractorHelper.java | 56 ++- .../schabi/newpipe/util/SparseItemUtil.java | 2 +- .../newpipe/util/SponsorBlockHelper.java | 190 ++++++++++ .../schabi/newpipe/util/SponsorBlockMode.java | 7 + .../org/schabi/newpipe/util/TimeUtils.java | 17 + .../util/text/InternalUrlsHandler.java | 2 +- .../newpipe/views/FocusAwareSeekBar.java | 4 +- .../schabi/newpipe/views/MarkableSeekBar.java | 109 ++++++ .../schabi/newpipe/views/SeekBarMarker.java | 26 ++ app/src/main/res/drawable/ic_chevron_left.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 10 + app/src/main/res/drawable/ic_fast_forward.xml | 10 + app/src/main/res/drawable/ic_fast_rewind.xml | 10 + .../res/drawable/ic_sponsor_block_disable.xml | 18 + .../res/drawable/ic_sponsor_block_enable.xml | 13 + .../res/drawable/ic_sponsor_block_exclude.xml | 12 + app/src/main/res/drawable/ic_upload.xml | 10 + .../activity_player_queue_control.xml | 2 +- .../layout/activity_player_queue_control.xml | 2 +- .../layout/dialog_sponsor_block_api_url.xml | 33 ++ .../dialog_sponsor_block_api_url_help.xml | 25 ++ .../res/layout/fragment_sponsor_block.xml | 181 ++++++++++ .../main/res/layout/list_segments_item.xml | 113 ++++++ .../main/res/layout/preference_edit_color.xml | 6 + app/src/main/res/values/colors.xml | 12 + app/src/main/res/values/settings_keys.xml | 32 ++ app/src/main/res/values/strings.xml | 91 +++++ app/src/main/res/xml/main_settings.xml | 6 + .../xml/sponsor_block_category_settings.xml | 257 ++++++++++++++ .../main/res/xml/sponsor_block_settings.xml | 60 ++++ 55 files changed, 2952 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistEntry.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragmentListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapterListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/sponsorblock/SponsorBlockDataManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/EditColorPreference.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/SponsorBlockApiUrlPreference.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SponsorBlockHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SponsorBlockMode.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/TimeUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/MarkableSeekBar.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/SeekBarMarker.java create mode 100644 app/src/main/res/drawable/ic_chevron_left.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_fast_forward.xml create mode 100644 app/src/main/res/drawable/ic_fast_rewind.xml create mode 100644 app/src/main/res/drawable/ic_sponsor_block_disable.xml create mode 100644 app/src/main/res/drawable/ic_sponsor_block_enable.xml create mode 100644 app/src/main/res/drawable/ic_sponsor_block_exclude.xml create mode 100644 app/src/main/res/drawable/ic_upload.xml create mode 100644 app/src/main/res/layout/dialog_sponsor_block_api_url.xml create mode 100644 app/src/main/res/layout/dialog_sponsor_block_api_url_help.xml create mode 100644 app/src/main/res/layout/fragment_sponsor_block.xml create mode 100644 app/src/main/res/layout/list_segments_item.xml create mode 100644 app/src/main/res/layout/preference_edit_color.xml create mode 100644 app/src/main/res/xml/sponsor_block_category_settings.xml create mode 100644 app/src/main/res/xml/sponsor_block_settings.xml diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json index a14f8b9a8..510eb497e 100644 --- a/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "012fc8e7ad3333f1597347f34e76a513", + "identityHash": "7dcdec7a500be9088f7a9a4767292b41", "entities": [ { "tableName": "subscriptions", @@ -58,10 +58,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "uid" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -107,10 +107,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -209,10 +209,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "uid" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -252,11 +252,11 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "stream_id", "access_date" - ], - "autoGenerate": false + ] }, "indices": [ { @@ -301,10 +301,10 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "stream_id" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [ @@ -351,10 +351,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "uid" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -393,11 +393,11 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "playlist_id", "join_index" - ], - "autoGenerate": false + ] }, "indices": [ { @@ -493,10 +493,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "uid" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -539,11 +539,11 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "stream_id", "subscription_id" - ], - "autoGenerate": false + ] }, "indices": [ { @@ -611,10 +611,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "uid" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -647,11 +647,11 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "group_id", "subscription_id" - ], - "autoGenerate": false + ] }, "indices": [ { @@ -707,10 +707,10 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "subscription_id" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [ @@ -726,12 +726,32 @@ ] } ] + }, + { + "tableName": "sponsorblock_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uploader` TEXT NOT NULL, PRIMARY KEY(`uploader`))", + "fields": [ + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uploader" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7dcdec7a500be9088f7a9a4767292b41')" ] } } \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 2caf89fcd..c47d64182 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -817,7 +817,8 @@ public class RouterActivity extends AppCompatActivity { inFlight(true); final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title); loadingDialog.show(getParentFragmentManager(), "loadingDialog"); - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) + disposables.add(ExtractorHelper.getStreamInfo(getContext(), currentServiceId, + currentUrl, true) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(this::pleaseWait) @@ -837,7 +838,8 @@ public class RouterActivity extends AppCompatActivity { private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { inFlight(true); - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) + disposables.add(ExtractorHelper.getStreamInfo(getContext(), currentServiceId, + currentUrl, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(this::pleaseWait) @@ -970,7 +972,8 @@ public class RouterActivity extends AppCompatActivity { switch (choice.linkType) { case STREAM: - single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); + single = ExtractorHelper.getStreamInfo( + this, choice.serviceId, choice.url, false); userAction = UserAction.REQUESTED_STREAM; break; case CHANNEL: diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java index 03e39cd43..d8db2f920 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -22,6 +22,8 @@ import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistDAO; +import org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry; import org.schabi.newpipe.database.stream.dao.StreamDAO; import org.schabi.newpipe.database.stream.dao.StreamStateDAO; import org.schabi.newpipe.database.stream.model.StreamEntity; @@ -36,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, - FeedLastUpdatedEntity.class + FeedLastUpdatedEntity.class, SponsorBlockWhitelistEntry.class }, version = DB_VER_7 ) @@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase { public abstract FeedGroupDAO feedGroupDAO(); public abstract SubscriptionDAO subscriptionDAO(); + + public abstract SponsorBlockWhitelistDAO sponsorBlockWhitelistDAO(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistDAO.java b/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistDAO.java new file mode 100644 index 000000000..cc1fe2130 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistDAO.java @@ -0,0 +1,35 @@ +package org.schabi.newpipe.database.sponsorblock.dao; + +import static org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry.SPONSORBLOCK_WHITELIST_TABLE; +import static org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry.UPLOADER; + +import androidx.room.Dao; +import androidx.room.Query; + +import org.schabi.newpipe.database.BasicDAO; + +import java.util.List; + +import io.reactivex.rxjava3.core.Flowable; + +@Dao +public abstract class SponsorBlockWhitelistDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + SPONSORBLOCK_WHITELIST_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + SPONSORBLOCK_WHITELIST_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(final int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("DELETE FROM " + SPONSORBLOCK_WHITELIST_TABLE + " WHERE " + UPLOADER + " = :uploader") + public abstract int deleteByUploader(String uploader); + + @Query("SELECT 1 FROM " + SPONSORBLOCK_WHITELIST_TABLE + " WHERE " + UPLOADER + " = :uploader") + public abstract boolean exists(String uploader); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistEntry.java b/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistEntry.java new file mode 100644 index 000000000..97622f9f4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/sponsorblock/dao/SponsorBlockWhitelistEntry.java @@ -0,0 +1,33 @@ +package org.schabi.newpipe.database.sponsorblock.dao; + +import static org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry.SPONSORBLOCK_WHITELIST_TABLE; +import static org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry.UPLOADER; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; + +@Entity(tableName = SPONSORBLOCK_WHITELIST_TABLE, + primaryKeys = {UPLOADER} +) +public class SponsorBlockWhitelistEntry { + public static final String SPONSORBLOCK_WHITELIST_TABLE = "sponsorblock_whitelist"; + public static final String UPLOADER = "uploader"; + + @NonNull + @ColumnInfo(name = UPLOADER) + private String uploader; + + public SponsorBlockWhitelistEntry(final @NonNull String uploader) { + this.uploader = uploader; + } + + @NonNull + public String getUploader() { + return uploader; + } + + public void setUploader(final @NonNull String uploader) { + this.uploader = uploader; + } +} 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 7db5e0251..c1f713710 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 @@ -76,6 +76,10 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockAction; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockCategory; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockExtractorHelper; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -86,11 +90,14 @@ import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.EmptyFragment; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment; +import org.schabi.newpipe.fragments.list.sponsorblock.SponsorBlockFragment; +import org.schabi.newpipe.fragments.list.sponsorblock.SponsorBlockFragmentListener; import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; +import org.schabi.newpipe.local.sponsorblock.SponsorBlockDataManager; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerType; @@ -110,12 +117,13 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.ArrayList; import java.util.Iterator; @@ -128,6 +136,7 @@ import java.util.function.Consumer; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -136,7 +145,8 @@ public final class VideoDetailFragment extends BaseStateFragment implements BackPressable, PlayerServiceExtendedEventListener, - OnKeyDownListener { + OnKeyDownListener, + SponsorBlockFragmentListener { public static final String KEY_SWITCHING_PLAYERS = "switching_players"; private static final float MAX_OVERLAY_ALPHA = 0.9f; @@ -156,6 +166,7 @@ public final class VideoDetailFragment private static final String COMMENTS_TAB_TAG = "COMMENTS"; private static final String RELATED_TAB_TAG = "NEXT VIDEO"; private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; + private static final String SPONSOR_BLOCK_TAB_TAG = "SPONSOR_BLOCK TAB"; private static final String EMPTY_TAB_TAG = "EMPTY TAB"; private static final String PICASSO_VIDEO_DETAILS_TAG = "PICASSO_VIDEO_DETAILS_TAG"; @@ -164,6 +175,7 @@ public final class VideoDetailFragment private boolean showComments; private boolean showRelatedItems; private boolean showDescription; + private boolean showSponsorBlock; private String selectedTabTag; @AttrRes @NonNull @@ -175,18 +187,25 @@ public final class VideoDetailFragment private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = - (sharedPreferences, key) -> { - if (getString(R.string.show_comments_key).equals(key)) { - showComments = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_next_video_key).equals(key)) { - showRelatedItems = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_description_key).equals(key)) { - showDescription = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } - }; + this::onSharedPreferencesChanged; + private Disposable workerIsWhitelisted; + + private void onSharedPreferencesChanged(final SharedPreferences sharedPreferences, + final String key) { + if (getString(R.string.show_comments_key).equals(key)) { + showComments = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_next_video_key).equals(key)) { + showRelatedItems = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_description_key).equals(key)) { + showDescription = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.sponsor_block_enable_key).equals(key)) { + showSponsorBlock = sharedPreferences.getBoolean(key, false); + tabSettingsChanged = true; + } + } @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -212,6 +231,8 @@ public final class VideoDetailFragment private final CompositeDisposable disposables = new CompositeDisposable(); @Nullable private Disposable positionSubscriber = null; + private Disposable submitSegmentSubscriber; + private BottomSheetBehavior bottomSheetBehavior; private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; @@ -230,6 +251,7 @@ public final class VideoDetailFragment private PlayerService playerService; private Player player; private final PlayerHolder playerHolder = PlayerHolder.getInstance(); + private SponsorBlockDataManager sponsorBlockDataManager; /*////////////////////////////////////////////////////////////////////////// // Service management @@ -309,6 +331,7 @@ public final class VideoDetailFragment showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); + showSponsorBlock = prefs.getBoolean(getString(R.string.sponsor_block_enable_key), false); selectedTabTag = prefs.getString( getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); @@ -326,6 +349,8 @@ public final class VideoDetailFragment activity.getContentResolver().registerContentObserver( Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, settingsContentObserver); + + sponsorBlockDataManager = new SponsorBlockDataManager(requireContext()); } @Override @@ -426,6 +451,17 @@ public final class VideoDetailFragment binding = null; } + @Override + public void onDetach() { + super.onDetach(); + if (submitSegmentSubscriber != null) { + submitSegmentSubscriber.dispose(); + } + if (workerIsWhitelisted != null) { + workerIsWhitelisted.dispose(); + } + } + @Override public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -552,10 +588,10 @@ public final class VideoDetailFragment })); binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> - openBackgroundPlayer(true) + openBackgroundPlayer(true) )); binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> - openPopupPlayer(true) + openPopupPlayer(true) )); binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> NavigationHelper.openDownloads(activity))); @@ -838,7 +874,7 @@ public final class VideoDetailFragment private void runWorker(final boolean forceLoad, final boolean addToBackStack) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + currentWorker = ExtractorHelper.getStreamInfo(getContext(), serviceId, url, forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { @@ -901,6 +937,13 @@ public final class VideoDetailFragment tabContentDescriptions.add(R.string.description_tab_description); } + if (showSponsorBlock) { + // temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(EmptyFragment.newInstance(false), SPONSOR_BLOCK_TAB_TAG); + tabIcons.add(R.drawable.ic_sponsor_block_enable); + tabContentDescriptions.add(R.string.sponsor_block_tab_description); + } + if (pageAdapter.getCount() == 0) { pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); } @@ -949,6 +992,13 @@ public final class VideoDetailFragment pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); } + if (showSponsorBlock) { + final SponsorBlockFragment sponsorBlockFragment = new SponsorBlockFragment(info); + sponsorBlockFragment.setListener(this); + + pageAdapter.updateItem(SPONSOR_BLOCK_TAB_TAG, sponsorBlockFragment); + } + binding.viewPager.setVisibility(View.VISIBLE); // make sure the tab layout is visible updateTabLayoutVisibility(); @@ -1243,6 +1293,9 @@ public final class VideoDetailFragment playerUi.removeViewFromParent(); binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); playerUi.setupVideoSurfaceIfNeeded(); + if (currentInfo != null) { + playerUi.onMarkSeekbarRequested(currentInfo); + } } }); }); @@ -1786,6 +1839,9 @@ public final class VideoDetailFragment return; } + getSponsorBlockFragment().ifPresent( + fragment -> fragment.setCurrentProgress(currentProgress)); + if (player.getPlayQueue().getItem().getUrl().equals(url)) { updatePlaybackProgress(currentProgress, duration); } @@ -1793,6 +1849,25 @@ public final class VideoDetailFragment @Override public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { + final Context context = requireContext(); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final boolean isSponsorBlockEnabled = + prefs.getBoolean(getString(R.string.sponsor_block_enable_key), false); + + if (player != null && isSponsorBlockEnabled) { + workerIsWhitelisted = sponsorBlockDataManager.isWhiteListed(info.getUploaderName()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(isWhitelisted -> { + final SponsorBlockMode mode = isWhitelisted + ? SponsorBlockMode.DISABLED + : SponsorBlockMode.ENABLED; + player.setSponsorBlockMode(mode); + getSponsorBlockFragment().ifPresent( + fragment -> fragment.setSponsorBlockMode(mode)); + }); + } final StackItem item = findQueueInStack(queue); if (item != null) { // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) @@ -2451,4 +2526,147 @@ public final class VideoDetailFragment lastStableBottomSheetState = newState; } } + + private Optional getSponsorBlockFragment() { + final int sponsorBlockTabPos = pageAdapter.getItemPositionByTitle(SPONSOR_BLOCK_TAB_TAG); + + if (sponsorBlockTabPos < 0) { + return Optional.empty(); + } + + final Fragment fragment = pageAdapter.getItem(sponsorBlockTabPos); + + if (fragment instanceof SponsorBlockFragment sponsorBlockFragment) { + return Optional.of(sponsorBlockFragment); + } else { + return Optional.empty(); + } + } + + @Override + public void onSkippingEnabledChanged(final boolean newValue) { + if (player == null) { + return; + } + + player.setSponsorBlockMode(newValue + ? SponsorBlockMode.ENABLED + : SponsorBlockMode.DISABLED); + } + + @Override + public void onRequestNewPendingSegment(final int startTime, final int endTime) { + if (currentInfo == null) { + return; + } + + if (player == null) { + return; + } + + currentInfo.removeSponsorBlockSegment("TEMP"); + + final SponsorBlockSegment segment = new SponsorBlockSegment( + "TEMP", + startTime, + endTime, + SponsorBlockCategory.PENDING, + SponsorBlockAction.SKIP); + + currentInfo.addSponsorBlockSegment(segment); + + player.UIs().get(MainPlayerUi.class).ifPresent( + playerUi -> playerUi.onMarkSeekbarRequested(currentInfo)); + + getSponsorBlockFragment().ifPresent(SponsorBlockFragment::refreshSponsorBlockSegments); + } + + @Override + public void onRequestClearPendingSegment() { + if (currentInfo == null) { + return; + } + + if (player == null) { + return; + } + + currentInfo.removeSponsorBlockSegment("TEMP"); + + player.UIs().get(MainPlayerUi.class).ifPresent( + playerUi -> playerUi.onMarkSeekbarRequested(currentInfo)); + + getSponsorBlockFragment().ifPresent(SponsorBlockFragment::refreshSponsorBlockSegments); + } + + @Override + public void onRequestSubmitPendingSegment(final SponsorBlockSegment newSegment) { + if (currentInfo == null) { + return; + } + + if (player == null) { + return; + } + + final Context context = requireContext(); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String apiUrl = prefs.getString(context + .getString(R.string.sponsor_block_api_url_key), null); + if (apiUrl == null || apiUrl.isEmpty()) { + return; + } + + submitSegmentSubscriber = Single.fromCallable(() -> + SponsorBlockExtractorHelper.submitSponsorBlockSegment( + currentInfo, + newSegment, + "")) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(response -> { + if (response.responseCode() != 200) { + String message = response.responseMessage(); + if (message.equals("")) { + message = "Error " + response.responseCode(); + } + Toast.makeText(context, + message, + Toast.LENGTH_SHORT).show(); + return; + } + + currentInfo.removeSponsorBlockSegment("TEMP"); + currentInfo.addSponsorBlockSegment(newSegment); + + player.UIs().get(MainPlayerUi.class).ifPresent( + playerUi -> playerUi.onMarkSeekbarRequested(currentInfo)); + + getSponsorBlockFragment().ifPresent( + SponsorBlockFragment::clearPendingSegment); + + new AlertDialog + .Builder(context) + .setMessage(R.string.sponsor_block_upload_success_message) + .setPositiveButton(R.string.ok, (d, w) -> d.dismiss()) + .show(); + }, throwable -> { + if (throwable instanceof NullPointerException) { + return; + } + ErrorUtil.showSnackbar(context, + new ErrorInfo(throwable, UserAction.USER_REPORT, + "Submit SponsorBlock segment")); + }); + } + + @Override + public void onSeekToRequested(final long positionMillis) { + if (player == null) { + return; + } + + player.seekTo(positionMillis); + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragment.java new file mode 100644 index 000000000..cad8f82d2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragment.java @@ -0,0 +1,333 @@ +package org.schabi.newpipe.fragments.list.sponsorblock; + +import static org.schabi.newpipe.util.TimeUtils.millisecondsToString; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.schabi.newpipe.BaseFragment; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.FragmentSponsorBlockBinding; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockAction; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockCategory; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.local.sponsorblock.SponsorBlockDataManager; +import org.schabi.newpipe.util.SponsorBlockHelper; +import org.schabi.newpipe.util.SponsorBlockMode; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class SponsorBlockFragment + extends BaseFragment + implements CompoundButton.OnCheckedChangeListener, + SponsorBlockSegmentListAdapterListener { + private StreamInfo streamInfo; + FragmentSponsorBlockBinding binding; + private Integer markedStartTime = null; + private Integer markedEndTime = null; + private SponsorBlockSegmentListAdapter segmentListAdapter; + private int currentProgress = -1; + private @Nullable SponsorBlockFragmentListener sponsorBlockFragmentListener; + private SponsorBlockDataManager sponsorBlockDataManager; + private Disposable workerIsWhitelisted; + private Disposable workerAddToWhitelisted; + private Disposable workerRemoveFromWhitelisted; + private SponsorBlockMode sponsorBlockMode = SponsorBlockMode.ENABLED; + + + public SponsorBlockFragment() { + } + + public SponsorBlockFragment(@NonNull final StreamInfo streamInfo) { + this.streamInfo = streamInfo; + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + sponsorBlockDataManager = new SponsorBlockDataManager(getContext()); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + + if (streamInfo == null) { + return; + } + + segmentListAdapter = new SponsorBlockSegmentListAdapter(context, this); + segmentListAdapter.setItems(streamInfo.getSponsorBlockSegments()); + } + + @Override + public void onDetach() { + super.onDetach(); + + if (workerIsWhitelisted != null) { + workerIsWhitelisted.dispose(); + } + if (workerAddToWhitelisted != null) { + workerAddToWhitelisted.dispose(); + } + if (workerRemoveFromWhitelisted != null) { + workerRemoveFromWhitelisted.dispose(); + } + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + if (sponsorBlockDataManager != null) { + sponsorBlockDataManager = new SponsorBlockDataManager(getContext()); + } + + binding = FragmentSponsorBlockBinding.inflate(inflater, container, false); + + binding.sponsorBlockControlsMarkSegmentStart.setOnClickListener(v -> + doMarkPendingSegment(true)); + binding.sponsorBlockControlsMarkSegmentEnd.setOnClickListener(v -> + doMarkPendingSegment(false)); + binding.sponsorBlockControlsSegmentStart.setOnClickListener(v -> + doPendingSegmentSeek(true)); + binding.sponsorBlockControlsSegmentEnd.setOnClickListener(v -> + doPendingSegmentSeek(false)); + binding.sponsorBlockControlsClearSegment.setOnClickListener(v -> + doClearPendingSegment()); + binding.sponsorBlockControlsSubmitSegment.setOnClickListener(v -> + doSubmitPendingSegment()); + + binding.segmentList.setAdapter(segmentListAdapter); + + workerIsWhitelisted = sponsorBlockDataManager.isWhiteListed(streamInfo.getUploaderName()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(isWhitelisted -> { + binding.channelIsWhitelistedSwitch.setChecked(isWhitelisted); + + binding.skippingIsEnabledSwitch.setChecked( + !isWhitelisted && sponsorBlockMode == SponsorBlockMode.ENABLED); + + binding.skippingIsEnabledSwitch.setOnCheckedChangeListener(this); + binding.channelIsWhitelistedSwitch.setOnCheckedChangeListener(this); + }); + + return binding.getRoot(); + } + + @Override + public void onCheckedChanged(final CompoundButton buttonView, final boolean isChecked) { + if (buttonView.getId() == R.id.skipping_is_enabled_switch) { + if (sponsorBlockFragmentListener != null) { + sponsorBlockFragmentListener.onSkippingEnabledChanged(isChecked); + } + } else if (buttonView.getId() == R.id.channel_is_whitelisted_switch) { + final Context context = requireContext(); + + final String toastText; + + if (isChecked) { + toastText = context.getString( + R.string.sponsor_block_uploader_added_to_whitelist_toast); + + workerAddToWhitelisted = + sponsorBlockDataManager.addToWhitelist(streamInfo.getUploaderName()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); + }, error -> { + // TODO + }); + } else { + toastText = context.getString( + R.string.sponsor_block_uploader_removed_from_whitelist_toast); + + workerRemoveFromWhitelisted = + sponsorBlockDataManager.removeFromWhitelist(streamInfo.getUploaderName()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); + }, error -> { + // TODO + }); + } + + binding.skippingIsEnabledSwitch.setChecked(false); + binding.skippingIsEnabledSwitch.setEnabled(!isChecked); + } + } + + public void setListener(final SponsorBlockFragmentListener listener) { + sponsorBlockFragmentListener = listener; + } + + public void setSponsorBlockMode(@NonNull final SponsorBlockMode mode) { + sponsorBlockMode = mode; + } + + public void setCurrentProgress(final int progress) { + currentProgress = progress; + } + + @SuppressLint("SetTextI18n") + public void clearPendingSegment() { + markedStartTime = null; + markedEndTime = null; + + binding.sponsorBlockControlsSegmentStart.setText("00:00:00"); + binding.sponsorBlockControlsSegmentEnd.setText("00:00:00"); + + if (sponsorBlockFragmentListener != null) { + sponsorBlockFragmentListener.onRequestClearPendingSegment(); + } + } + + public void refreshSponsorBlockSegments() { + if (segmentListAdapter == null) { + return; + } + + segmentListAdapter.setItems(streamInfo.getSponsorBlockSegments()); + } + + private void doMarkPendingSegment(final boolean isStart) { + if (currentProgress < 0) { + return; + } + + if (isStart) { + if (markedEndTime != null && currentProgress > markedEndTime) { + Toast.makeText(getContext(), + getString(R.string.sponsor_block_invalid_start_toast), + Toast.LENGTH_SHORT).show(); + return; + } + markedStartTime = currentProgress; + } else { + if (markedStartTime != null && currentProgress < markedStartTime) { + Toast.makeText(getContext(), + getString(R.string.sponsor_block_invalid_end_toast), + Toast.LENGTH_SHORT).show(); + return; + } + markedEndTime = currentProgress; + } + + if (markedStartTime != null) { + binding.sponsorBlockControlsSegmentStart.setText( + millisecondsToString(markedStartTime)); + } + + if (markedEndTime != null) { + binding.sponsorBlockControlsSegmentEnd.setText( + millisecondsToString(markedEndTime)); + } + + if (markedStartTime != null && markedEndTime != null) { + if (sponsorBlockFragmentListener != null) { + sponsorBlockFragmentListener.onRequestNewPendingSegment( + markedStartTime, markedEndTime); + } + } + + final String message = isStart + ? getString(R.string.sponsor_block_marked_start_toast) + : getString(R.string.sponsor_block_marked_end_toast); + Toast.makeText(getContext(), + message, + Toast.LENGTH_SHORT).show(); + } + + @SuppressLint("SetTextI18n") + private void doClearPendingSegment() { + new AlertDialog + .Builder(requireContext()) + .setMessage(R.string.sponsor_block_clear_marked_segment_prompt) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton(R.string.yes, (dialog, which) -> { + clearPendingSegment(); + dialog.dismiss(); + }) + .show(); + } + + private void doPendingSegmentSeek(final boolean isStart) { + if (isStart && markedStartTime != null) { + onSkipToTimestampRequested((long) markedStartTime); + } else if (markedEndTime != null) { + onSkipToTimestampRequested((long) markedEndTime); + } + } + + private void doSubmitPendingSegment() { + final Context context = requireContext(); + + if (markedStartTime == null || markedEndTime == null) { + Toast.makeText(context, + getString(R.string.sponsor_block_missing_times_toast), + Toast.LENGTH_SHORT).show(); + return; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.sponsor_block_select_a_category); + builder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()); + builder.setItems(new String[]{ + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.SPONSOR), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.INTRO), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.OUTRO), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.INTERACTION), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.HIGHLIGHT), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.SELF_PROMO), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.NON_MUSIC), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.PREVIEW), + SponsorBlockHelper.convertCategoryToFriendlyName( + context, SponsorBlockCategory.FILLER) + }, (dialog, which) -> { + final SponsorBlockCategory category = SponsorBlockCategory.values()[which]; + final SponsorBlockAction action = category == SponsorBlockCategory.HIGHLIGHT + ? SponsorBlockAction.POI + : SponsorBlockAction.SKIP; + final SponsorBlockSegment newSegment = + new SponsorBlockSegment( + "", markedStartTime, markedEndTime, category, action); + if (sponsorBlockFragmentListener != null) { + sponsorBlockFragmentListener.onRequestSubmitPendingSegment(newSegment); + } + dialog.dismiss(); + }); + builder.show(); + } + + @Override + public void onSkipToTimestampRequested(final long positionMillis) { + if (sponsorBlockFragmentListener != null) { + sponsorBlockFragmentListener.onSeekToRequested(positionMillis); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragmentListener.java b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragmentListener.java new file mode 100644 index 000000000..cdfc605cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockFragmentListener.java @@ -0,0 +1,11 @@ +package org.schabi.newpipe.fragments.list.sponsorblock; + +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; + +public interface SponsorBlockFragmentListener { + void onSkippingEnabledChanged(boolean newValue); + void onRequestNewPendingSegment(int startTime, int endTime); + void onRequestClearPendingSegment(); + void onRequestSubmitPendingSegment(SponsorBlockSegment newSegment); + void onSeekToRequested(long positionMillis); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapter.java new file mode 100644 index 000000000..a06fa68c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapter.java @@ -0,0 +1,294 @@ +package org.schabi.newpipe.fragments.list.sponsorblock; + +import static org.schabi.newpipe.util.TimeUtils.millisecondsToString; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockCategory; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockExtractorHelper; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; +import org.schabi.newpipe.util.SponsorBlockHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class SponsorBlockSegmentListAdapter extends + RecyclerView.Adapter { + private final Context context; + private ArrayList sponsorBlockSegments = new ArrayList<>(); + private final SponsorBlockSegmentListAdapterListener listener; + + public SponsorBlockSegmentListAdapter(final Context context, + final SponsorBlockSegmentListAdapterListener listener) { + this.context = context; + this.listener = listener; + } + + public void setItems(final SponsorBlockSegment[] items) { + if (items == null) { + sponsorBlockSegments.clear(); + } else { + sponsorBlockSegments = new ArrayList<>(Arrays.asList(items)); + } + + // find the first "highlight" segment (if it exists) and move it to the top + if (sponsorBlockSegments.size() > 0) { + final Optional highlightSegment = + sponsorBlockSegments + .stream() + .filter(x -> x.category == SponsorBlockCategory.HIGHLIGHT) + .findFirst(); + + if (highlightSegment.isPresent()) { + sponsorBlockSegments.remove(highlightSegment.get()); + sponsorBlockSegments.add(0, highlightSegment.get()); + } + } + + notifyDataSetChanged(); + } + + @NonNull + @Override + public SponsorBlockSegmentListAdapter.SponsorBlockSegmentItemViewHolder onCreateViewHolder( + @NonNull final ViewGroup parent, final int viewType) { + final View itemView = LayoutInflater + .from(context) + .inflate(R.layout.list_segments_item, parent, false); + return new SponsorBlockSegmentItemViewHolder(itemView, listener); + } + + @Override + public void onBindViewHolder( + @NonNull final SponsorBlockSegmentListAdapter.SponsorBlockSegmentItemViewHolder holder, + final int position) { + final SponsorBlockSegment sponsorBlockSegment = sponsorBlockSegments.get(position); + holder.updateFrom(sponsorBlockSegment); + } + + @Override + public int getItemCount() { + return sponsorBlockSegments.size(); + } + + public static class SponsorBlockSegmentItemViewHolder extends RecyclerView.ViewHolder { + private final View itemSegmentColorView; + private final ImageView itemSegmentSkipToHighlight; + private final TextView itemSegmentNameTextView; + private final TextView itemSegmentStartTimeTextView; + private final TextView itemSegmentEndTimeTextView; + private final ImageView itemSegmentVoteUpImageView; + private final ImageView itemSegmentVoteDownImageView; + private Disposable voteSubscriber; + private String segmentUuid; + private boolean isVoting; + private boolean hasUpVoted; + private boolean hasDownVoted; + private boolean hasResetVote; + private SponsorBlockSegment currentSponsorBlockSegment; + + public SponsorBlockSegmentItemViewHolder( + @NonNull final View itemView, + final SponsorBlockSegmentListAdapterListener listener) { + super(itemView); + + itemSegmentColorView = itemView.findViewById(R.id.item_segment_color_view); + itemSegmentSkipToHighlight = itemView.findViewById(R.id.item_segment_skip_to_highlight); + itemSegmentSkipToHighlight.setOnClickListener(v -> { + if (currentSponsorBlockSegment != null && listener != null) { + listener.onSkipToTimestampRequested( + (long) currentSponsorBlockSegment.startTime); + } + }); + itemSegmentNameTextView = itemView.findViewById( + R.id.item_segment_category_name_textview); + itemSegmentStartTimeTextView = itemView.findViewById( + R.id.item_segment_start_time_textview); + itemSegmentStartTimeTextView.setOnClickListener(v -> { + if (currentSponsorBlockSegment != null && listener != null) { + listener.onSkipToTimestampRequested( + (long) currentSponsorBlockSegment.startTime); + } + }); + itemSegmentEndTimeTextView = itemView.findViewById(R.id.item_segment_end_time_textview); + itemSegmentEndTimeTextView.setOnClickListener(v -> { + if (currentSponsorBlockSegment != null && listener != null) { + listener.onSkipToTimestampRequested((long) currentSponsorBlockSegment.endTime); + } + }); + + // voting: + // 1 = up + // 0 = down + // 20 = reset + itemSegmentVoteUpImageView = + itemView.findViewById(R.id.item_segment_vote_up_imageview); + itemSegmentVoteUpImageView.setOnClickListener(v -> vote(1)); + itemSegmentVoteUpImageView.setOnLongClickListener(v -> { + vote(20); + return true; + }); + itemSegmentVoteDownImageView = + itemView.findViewById(R.id.item_segment_vote_down_imageview); + itemSegmentVoteDownImageView.setOnClickListener(v -> vote(0)); + itemSegmentVoteDownImageView.setOnLongClickListener(v -> { + vote(20); + return true; + }); + } + + private void updateFrom(final SponsorBlockSegment sponsorBlockSegment) { + currentSponsorBlockSegment = sponsorBlockSegment; + + final Context context = itemView.getContext(); + + // uuid + segmentUuid = sponsorBlockSegment.uuid; + + // category color + final Integer segmentColor = + SponsorBlockHelper.convertCategoryToColor( + sponsorBlockSegment.category, context); + if (segmentColor != null) { + itemSegmentColorView.setBackgroundColor(segmentColor); + } + + // skip to highlight + if (sponsorBlockSegment.category == SponsorBlockCategory.HIGHLIGHT) { + itemSegmentColorView.setVisibility(View.GONE); + itemSegmentSkipToHighlight.setVisibility(View.VISIBLE); + } else { + itemSegmentColorView.setVisibility(View.VISIBLE); + itemSegmentSkipToHighlight.setVisibility(View.GONE); + } + + // category name + final String friendlyCategoryName = + SponsorBlockHelper.convertCategoryToFriendlyName( + context, sponsorBlockSegment.category); + itemSegmentNameTextView.setText(friendlyCategoryName); + + // from + final String startText = millisecondsToString(sponsorBlockSegment.startTime); + itemSegmentStartTimeTextView.setText(startText); + + // to + final String endText = millisecondsToString(sponsorBlockSegment.endTime); + itemSegmentEndTimeTextView.setText(endText); + + if (sponsorBlockSegment.category == SponsorBlockCategory.PENDING + || sponsorBlockSegment.uuid.equals("TEMP") + || sponsorBlockSegment.uuid.equals("")) { + itemSegmentVoteUpImageView.setVisibility(View.INVISIBLE); + itemSegmentVoteDownImageView.setVisibility(View.INVISIBLE); + } + } + + private void vote(final int value) { + if (segmentUuid == null) { + return; + } + + if (isVoting) { + return; + } + + if (voteSubscriber != null) { + voteSubscriber.dispose(); + } + + // these 3 checks prevent the user from continuously spamming votes + // (not entirely sure if we need this) + + if (value == 0 && hasDownVoted) { + return; + } + + if (value == 1 && hasUpVoted) { + return; + } + + if (value == 20 && hasResetVote) { + return; + } + + final Context context = itemView.getContext(); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String apiUrl = prefs.getString(context + .getString(R.string.sponsor_block_api_url_key), null); + if (apiUrl == null || apiUrl.isEmpty()) { + return; + } + + voteSubscriber = Single.fromCallable(() -> { + isVoting = true; + return SponsorBlockExtractorHelper.submitSponsorBlockSegmentVote( + segmentUuid, apiUrl, value); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(response -> { + isVoting = false; + String toastMessage; + if (response.responseCode() != 200) { + toastMessage = response.responseMessage(); + if (toastMessage.equals("")) { + toastMessage = "Error " + response.responseCode(); + } + } else if (value == 0) { + hasDownVoted = true; + hasUpVoted = false; + hasResetVote = false; + toastMessage = context.getString( + R.string.sponsor_block_segment_voted_down_toast); + } else if (value == 1) { + hasDownVoted = false; + hasUpVoted = true; + hasResetVote = false; + toastMessage = context.getString( + R.string.sponsor_block_segment_voted_up_toast); + } else if (value == 20) { + hasDownVoted = false; + hasUpVoted = false; + hasResetVote = true; + toastMessage = context.getString( + R.string.sponsor_block_segment_reset_vote_toast); + } else { + return; + } + Toast.makeText(context, + toastMessage, + Toast.LENGTH_SHORT).show(); + }, throwable -> { + if (throwable instanceof NullPointerException) { + return; + } + ErrorUtil.showSnackbar(context, + new ErrorInfo(throwable, UserAction.SUBSCRIPTION_UPDATE, + "Submit vote for SponsorBlock segment")); + }); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapterListener.java b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapterListener.java new file mode 100644 index 000000000..3939e9eff --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/sponsorblock/SponsorBlockSegmentListAdapterListener.java @@ -0,0 +1,5 @@ +package org.schabi.newpipe.fragments.list.sponsorblock; + +public interface SponsorBlockSegmentListAdapterListener { + void onSkipToTimestampRequested(long positionMillis); +} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index ed3cf548f..9be9a9034 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -59,6 +59,7 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; public class HistoryRecordManager { + private final Context context; private final AppDatabase database; private final StreamDAO streamTable; private final StreamHistoryDAO streamHistoryTable; @@ -69,6 +70,7 @@ public class HistoryRecordManager { private final String streamHistoryKey; public HistoryRecordManager(final Context context) { + this.context = context; database = NewPipeDatabase.getInstance(context); streamTable = database.streamDAO(); streamHistoryTable = database.streamHistoryDAO(); @@ -103,6 +105,7 @@ public class HistoryRecordManager { // Duration will not exist if the item was loaded with fast mode, so fetch it if empty if (info.getDuration() < 0) { final StreamInfo completeInfo = ExtractorHelper.getStreamInfo( + context, info.getServiceId(), info.getUrl(), false @@ -235,7 +238,7 @@ public class HistoryRecordManager { /////////////////////////////////////////////////////// public Maybe loadStreamState(final PlayQueueItem queueItem) { - return queueItem.getStream() + return queueItem.getStream(context) .map(info -> streamTable.upsert(new StreamEntity(info))) .flatMapPublisher(streamStateTable::getState) .firstElement() diff --git a/app/src/main/java/org/schabi/newpipe/local/sponsorblock/SponsorBlockDataManager.java b/app/src/main/java/org/schabi/newpipe/local/sponsorblock/SponsorBlockDataManager.java new file mode 100644 index 000000000..3df9ea762 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/sponsorblock/SponsorBlockDataManager.java @@ -0,0 +1,42 @@ +package org.schabi.newpipe.local.sponsorblock; + +import android.content.Context; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistDAO; +import org.schabi.newpipe.database.sponsorblock.dao.SponsorBlockWhitelistEntry; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class SponsorBlockDataManager { + private final SponsorBlockWhitelistDAO sponsorBlockWhitelistTable; + + public SponsorBlockDataManager(final Context context) { + final AppDatabase database = NewPipeDatabase.getInstance(context); + sponsorBlockWhitelistTable = database.sponsorBlockWhitelistDAO(); + } + + public Maybe addToWhitelist(final String uploader) { + return Maybe.fromCallable(() -> { + final SponsorBlockWhitelistEntry entry = new SponsorBlockWhitelistEntry(uploader); + return sponsorBlockWhitelistTable.insert(entry); + }).subscribeOn(Schedulers.io()); + } + + public Completable removeFromWhitelist(final String uploader) { + return Completable.fromAction(() -> sponsorBlockWhitelistTable.deleteByUploader(uploader)); + } + + public Single isWhiteListed(final String uploader) { + return Single.fromCallable(() -> sponsorBlockWhitelistTable.exists(uploader)) + .subscribeOn(Schedulers.io()); + } + + public Completable clearWhitelist() { + return Completable.fromAction(sponsorBlockWhitelistTable::deleteAll); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index c012f6008..f4faa1c10 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -46,6 +46,7 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.SponsorBlockHelper; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; @@ -229,9 +230,12 @@ public final class PlayQueueActivity extends AppCompatActivity } else { onQueueUpdate(player.getPlayQueue()); buildComponents(); - if (player != null) { - player.setActivityListener(PlayQueueActivity.this); - } + player.setActivityListener(PlayQueueActivity.this); + player.getCurrentStreamInfo().ifPresent(info -> + SponsorBlockHelper.markSegments( + getApplicationContext(), + queueControlBinding.seekBar, + info)); } } }; diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 49e72328e..dd35ebc92 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -57,6 +57,7 @@ import android.graphics.drawable.Drawable; import android.media.AudioManager; import android.util.Log; import android.view.LayoutInflater; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -69,6 +70,7 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.PositionInfo; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.source.MediaSource; @@ -86,6 +88,8 @@ import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockAction; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -118,6 +122,8 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.SponsorBlockMode; +import org.schabi.newpipe.util.SponsorBlockHelper; import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; @@ -262,7 +268,9 @@ public final class Player implements PlaybackListener, Listener { private final SharedPreferences prefs; @NonNull private final HistoryRecordManager recordManager; + private SponsorBlockMode sponsorBlockMode = SponsorBlockMode.DISABLED; + private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; /*////////////////////////////////////////////////////////////////////////// // Constructor @@ -273,6 +281,25 @@ public final class Player implements PlaybackListener, Listener { this.service = service; context = service; prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean isSponsorBlockEnabled = prefs.getBoolean( + context.getString(R.string.sponsor_block_enable_key), false); + + setSponsorBlockMode(isSponsorBlockEnabled + ? SponsorBlockMode.ENABLED + : SponsorBlockMode.DISABLED); + + preferenceChangeListener = + (sharedPreferences, key) -> { + if (context.getString(R.string.sponsor_block_enable_key).equals(key)) { + setSponsorBlockMode(sharedPreferences.getBoolean(key, false) + ? SponsorBlockMode.ENABLED + : SponsorBlockMode.DISABLED); + } + }; + + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); + recordManager = new HistoryRecordManager(context); setupBroadcastReceiver(); @@ -637,7 +664,7 @@ public final class Player implements PlaybackListener, Listener { } if (playQueue != null) { - playQueueManager = new MediaSourceManager(this, playQueue); + playQueueManager = new MediaSourceManager(context, this, playQueue); } } @@ -923,12 +950,61 @@ public final class Player implements PlaybackListener, Listener { } public void triggerProgressUpdate() { + triggerProgressUpdate(false); + } + + public void triggerProgressUpdate(final boolean isRewind) { if (exoPlayerIsNull()) { return; } - onUpdateProgress(Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), - (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); + final int currentProgress = Math.max((int) simpleExoPlayer.getCurrentPosition(), 0); + + onUpdateProgress( + currentProgress, + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage()); + + triggerCheckForSponsorBlockSegments(currentProgress, isRewind); + } + + private void triggerCheckForSponsorBlockSegments(final int currentProgress, + final boolean isRewind) { + if (sponsorBlockMode != SponsorBlockMode.ENABLED || !isPrepared) { + return; + } + + getSkippableSponsorBlockSegment(currentProgress).ifPresent(sponsorBlockSegment -> { + int skipTarget = isRewind + ? (int) Math.ceil((sponsorBlockSegment.startTime)) - 1 + : (int) Math.ceil((sponsorBlockSegment.endTime)); + + if (skipTarget < 0) { + skipTarget = 0; + } + + // temporarily force EXACT seek parameters to prevent infinite skip looping + final SeekParameters seekParams = simpleExoPlayer.getSeekParameters(); + simpleExoPlayer.setSeekParameters(SeekParameters.EXACT); + + seekTo(skipTarget); + + simpleExoPlayer.setSeekParameters(seekParams); + + if (prefs.getBoolean( + context.getString(R.string.sponsor_block_notifications_key), false)) { + final String toastText = + SponsorBlockHelper.convertCategoryToSkipMessage( + context, sponsorBlockSegment.category); + + Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show(); + } + + if (DEBUG) { + Log.d("SPONSOR_BLOCK", "Skipped segment: currentProgress = [" + + currentProgress + "], skipped to = [" + skipTarget + "]"); + } + }); } private Disposable getProgressUpdateDisposable() { @@ -1706,7 +1782,7 @@ public final class Player implements PlaybackListener, Listener { public void fastForward() { if (DEBUG) { - Log.d(TAG, "fastRewind() called"); + Log.d(TAG, "fastForward() called"); } seekBy(retrieveSeekDurationFromPreferences(this)); triggerProgressUpdate(); @@ -1717,7 +1793,7 @@ public final class Player implements PlaybackListener, Listener { Log.d(TAG, "fastRewind() called"); } seekBy(-retrieveSeekDurationFromPreferences(this)); - triggerProgressUpdate(); + triggerProgressUpdate(true); } //endregion @@ -2302,6 +2378,41 @@ public final class Player implements PlaybackListener, Listener { return Optional.ofNullable(fragmentListener); } + public SponsorBlockMode getSponsorBlockMode() { + return sponsorBlockMode; + } + + public void setSponsorBlockMode(final SponsorBlockMode mode) { + sponsorBlockMode = mode; + } + + public Optional getSkippableSponsorBlockSegment(final int progress) { + return getCurrentStreamInfo().map(info -> { + final SponsorBlockSegment[] sponsorBlockSegments = info.getSponsorBlockSegments(); + if (sponsorBlockSegments == null) { + return null; + } + + for (final SponsorBlockSegment sponsorBlockSegment : sponsorBlockSegments) { + if (sponsorBlockSegment.action != SponsorBlockAction.SKIP) { + continue; + } + + if (progress < sponsorBlockSegment.startTime) { + continue; + } + + if (progress > sponsorBlockSegment.endTime) { + continue; + } + + return sponsorBlockSegment; + } + + return null; + }); + } + /** * @return the user interfaces connected with the player */ diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerListener.java b/app/src/main/java/org/schabi/newpipe/player/PlayerListener.java new file mode 100644 index 000000000..c7998b8b9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerListener.java @@ -0,0 +1,5 @@ +package org.schabi.newpipe.player; + +public interface PlayerListener { + void onPlayerPrepared(Player player); +} 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 index 88d7145bc..dbe103cfa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -1,5 +1,11 @@ package org.schabi.newpipe.player.playback; +import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; +import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; +import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; +import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; + +import android.content.Context; import android.os.Handler; import android.util.Log; @@ -38,11 +44,6 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; -import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; -import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; -import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; -import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; - public class MediaSourceManager { @NonNull private final String TAG = "MediaSourceManager@" + hashCode(); @@ -69,6 +70,8 @@ public class MediaSourceManager { */ private static final int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; + @NonNull + private final Context context; @NonNull private final PlaybackListener playbackListener; @NonNull @@ -125,14 +128,16 @@ public class MediaSourceManager { private final Handler removeMediaSourceHandler = new Handler(); - public MediaSourceManager(@NonNull final PlaybackListener listener, + public MediaSourceManager(@NonNull final Context context, + @NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 400L, + this(context, listener, playQueue, 400L, /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); } - private MediaSourceManager(@NonNull final PlaybackListener listener, + private MediaSourceManager(@NonNull final Context context, + @NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final long loadDebounceMillis, final long playbackNearEndGapMillis, @@ -146,6 +151,7 @@ public class MediaSourceManager { + " ms] for them to be useful."); } + this.context = context; this.playbackListener = listener; this.playQueue = playQueue; @@ -420,7 +426,7 @@ public class MediaSourceManager { } private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { - return stream.getStream() + return stream.getStream(context) .map(streamInfo -> Optional .ofNullable(playbackListener.sourceOf(stream, streamInfo)) .flatMap(source -> diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java index 759c51267..0e1d58cd0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.player.playqueue; +import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -122,8 +124,8 @@ public class PlayQueueItem implements Serializable { } @NonNull - public Single getStream() { - return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) + public Single getStream(final Context context) { + return ExtractorHelper.getStreamInfo(context, this.serviceId, this.url, false) .subscribeOn(Schedulers.io()) .doOnError(throwable -> error = throwable); } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java index 57e2ec2a2..f66fff31b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java @@ -127,6 +127,9 @@ public abstract class PlayerUi { public void onPrepared() { } + public void onMarkSeekbarRequested(@NonNull final StreamInfo streamInfo) { + } + public void onBlocked() { } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java index b51aaa638..28ef09f46 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -82,6 +82,7 @@ import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.SponsorBlockHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; @@ -797,6 +798,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); } + @Override + public void onMarkSeekbarRequested(@NonNull final StreamInfo streamInfo) { + SponsorBlockHelper.markSegments(context, binding.playbackSeekBar, streamInfo); + } + @Override public void onBlocked() { super.onBlocked(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 1b9be4cbf..1103eaffa 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -63,6 +63,8 @@ public final class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true); saveDefaultVideoDownloadDirectory(context); saveDefaultAudioDownloadDirectory(context); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index b3d0741bb..39fdd5dbb 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -41,6 +41,8 @@ public final class SettingsResourceRegistry { add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); + add(SponsorBlockSettingsFragment.class, R.xml.sponsor_block_settings); + add(SponsorBlockCategoriesSettingsFragment.class, R.xml.sponsor_block_category_settings); } private SettingRegistryEntry add( diff --git a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java new file mode 100644 index 000000000..20cd7ceb4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java @@ -0,0 +1,154 @@ +package org.schabi.newpipe.settings; + +import android.content.SharedPreferences; +import android.os.Bundle; + +import androidx.annotation.ColorRes; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; +import androidx.preference.SwitchPreference; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.settings.custom.EditColorPreference; + +public class SponsorBlockCategoriesSettingsFragment extends BasePreferenceFragment { + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + + final Preference allOnPreference = + findPreference(getString(R.string.sponsor_block_category_all_on_key)); + allOnPreference.setOnPreferenceClickListener(p -> { + final SwitchPreference sponsorCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_sponsor_key)); + final SwitchPreference introCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_intro_key)); + final SwitchPreference outroCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_outro_key)); + final SwitchPreference interactionCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_interaction_key)); + final SwitchPreference highlightCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_highlight_key)); + final SwitchPreference selfPromoCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_self_promo_key)); + final SwitchPreference nonMusicCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_non_music_key)); + final SwitchPreference previewCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_preview_key)); + final SwitchPreference fillerCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_filler_key)); + + sponsorCategoryPreference.setChecked(true); + introCategoryPreference.setChecked(true); + outroCategoryPreference.setChecked(true); + interactionCategoryPreference.setChecked(true); + highlightCategoryPreference.setChecked(true); + selfPromoCategoryPreference.setChecked(true); + nonMusicCategoryPreference.setChecked(true); + previewCategoryPreference.setChecked(true); + fillerCategoryPreference.setChecked(true); + + return true; + }); + + final Preference allOffPreference = + findPreference(getString(R.string.sponsor_block_category_all_off_key)); + allOffPreference.setOnPreferenceClickListener(p -> { + final SwitchPreference sponsorCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_sponsor_key)); + final SwitchPreference introCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_intro_key)); + final SwitchPreference outroCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_outro_key)); + final SwitchPreference interactionCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_interaction_key)); + final SwitchPreference highlightCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_highlight_key)); + final SwitchPreference selfPromoCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_self_promo_key)); + final SwitchPreference nonMusicCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_non_music_key)); + final SwitchPreference previewCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_preview_key)); + final SwitchPreference fillerCategoryPreference = + findPreference(getString(R.string.sponsor_block_category_filler_key)); + + sponsorCategoryPreference.setChecked(false); + introCategoryPreference.setChecked(false); + outroCategoryPreference.setChecked(false); + interactionCategoryPreference.setChecked(false); + highlightCategoryPreference.setChecked(false); + selfPromoCategoryPreference.setChecked(false); + nonMusicCategoryPreference.setChecked(false); + previewCategoryPreference.setChecked(false); + fillerCategoryPreference.setChecked(false); + + return true; + }); + + final Preference resetPreference = + findPreference(getString(R.string.sponsor_block_category_reset_key)); + resetPreference.setOnPreferenceClickListener(p -> { + new AlertDialog.Builder(p.getContext()) + .setMessage(R.string.sponsor_block_confirm_reset_colors) + .setPositiveButton(R.string.yes, (dialog, which) -> { + final SharedPreferences.Editor editor = + getPreferenceManager() + .getSharedPreferences() + .edit(); + + setColorPreference(editor, + R.string.sponsor_block_category_sponsor_color_key, + R.color.sponsor_segment); + setColorPreference(editor, + R.string.sponsor_block_category_intro_color_key, + R.color.intro_segment); + setColorPreference(editor, + R.string.sponsor_block_category_outro_color_key, + R.color.outro_segment); + setColorPreference(editor, + R.string.sponsor_block_category_interaction_color_key, + R.color.interaction_segment); + setColorPreference(editor, + R.string.sponsor_block_category_highlight_color_key, + R.color.highlight_segment); + setColorPreference(editor, + R.string.sponsor_block_category_self_promo_color_key, + R.color.self_promo_segment); + setColorPreference(editor, + R.string.sponsor_block_category_non_music_color_key, + R.color.non_music_segment); + setColorPreference(editor, + R.string.sponsor_block_category_preview_color_key, + R.color.preview_segment); + setColorPreference(editor, + R.string.sponsor_block_category_filler_color_key, + R.color.filler_segment); + setColorPreference(editor, + R.string.sponsor_block_category_pending_color_key, + R.color.pending_segment); + + editor.apply(); + }) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + return true; + }); + } + + private void setColorPreference(final SharedPreferences.Editor editor, + @StringRes final int resId, + @ColorRes final int colorId) { + final String colorStr = "#" + Integer.toHexString(getResources().getColor(colorId)); + editor.putString(getString(resId), colorStr); + final EditColorPreference colorPreference = findPreference(getString(resId)); + colorPreference.setText(colorStr); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java new file mode 100644 index 000000000..024834ceb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java @@ -0,0 +1,86 @@ +package org.schabi.newpipe.settings; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.local.sponsorblock.SponsorBlockDataManager; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class SponsorBlockSettingsFragment extends BasePreferenceFragment { + private SponsorBlockDataManager sponsorBlockDataManager; + private Disposable workerClearWhitelist; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + sponsorBlockDataManager = new SponsorBlockDataManager(getContext()); + } + + @Override + public void onDetach() { + super.onDetach(); + if (workerClearWhitelist != null) { + workerClearWhitelist.dispose(); + } + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + + final Preference sponsorBlockWebsitePreference = + findPreference(getString(R.string.sponsor_block_home_page_key)); + assert sponsorBlockWebsitePreference != null; + sponsorBlockWebsitePreference.setOnPreferenceClickListener((Preference p) -> { + final Intent i = new Intent(Intent.ACTION_VIEW, + Uri.parse(getString(R.string.sponsor_block_homepage_url))); + startActivity(i); + return true; + }); + + final Preference sponsorBlockPrivacyPreference = + findPreference(getString(R.string.sponsor_block_privacy_key)); + assert sponsorBlockPrivacyPreference != null; + sponsorBlockPrivacyPreference.setOnPreferenceClickListener((Preference p) -> { + final Intent i = new Intent(Intent.ACTION_VIEW, + Uri.parse(getString(R.string.sponsor_block_privacy_policy_url))); + startActivity(i); + return true; + }); + + final Preference sponsorBlockClearWhitelistPreference = + findPreference(getString(R.string.sponsor_block_clear_whitelist_key)); + assert sponsorBlockClearWhitelistPreference != null; + sponsorBlockClearWhitelistPreference.setOnPreferenceClickListener((Preference p) -> { + new AlertDialog.Builder(p.getContext()) + .setMessage(R.string.sponsor_block_confirm_clear_whitelist) + .setPositiveButton(R.string.yes, (dialog, which) -> { + workerClearWhitelist = + sponsorBlockDataManager.clearWhitelist() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + Toast.makeText(p.getContext(), + R.string.sponsor_block_whitelist_cleared_toast, + Toast.LENGTH_SHORT).show(); + }, error -> { + // TODO + }); + }) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/EditColorPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/EditColorPreference.java new file mode 100644 index 000000000..633c52afc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/EditColorPreference.java @@ -0,0 +1,81 @@ +package org.schabi.newpipe.settings.custom; + +import android.content.Context; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Toast; + +import androidx.preference.EditTextPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.schabi.newpipe.R; + +public class EditColorPreference extends EditTextPreference + implements Preference.OnPreferenceChangeListener { + private PreferenceViewHolder viewHolder; + + public EditColorPreference(final Context context, final AttributeSet attrs, + final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public EditColorPreference(final Context context, final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public EditColorPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(); + } + + public EditColorPreference(final Context context) { + super(context); + init(); + } + + private void init() { + setWidgetLayoutResource(R.layout.preference_edit_color); + setOnPreferenceChangeListener(this); + } + + @Override + public void onBindViewHolder(final PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + viewHolder = holder; + + final String colorStr = + getPreferenceManager() + .getSharedPreferences() + .getString(getKey(), null); + + if (colorStr == null) { + return; + } + + final int color = Color.parseColor(colorStr); + + final View view = viewHolder.findViewById(R.id.sponsor_block_segment_color_view); + view.setBackgroundColor(color); + } + + @Override + public boolean onPreferenceChange(final Preference preference, final Object newValue) { + try { + final int color = Color.parseColor((String) newValue); + + final View view = viewHolder.findViewById(R.id.sponsor_block_segment_color_view); + view.setBackgroundColor(color); + + return true; + } catch (final Exception e) { + Toast.makeText(getContext(), R.string.invalid_color_toast, Toast.LENGTH_SHORT).show(); + return false; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/SponsorBlockApiUrlPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/SponsorBlockApiUrlPreference.java new file mode 100644 index 000000000..1a8940584 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/SponsorBlockApiUrlPreference.java @@ -0,0 +1,104 @@ +package org.schabi.newpipe.settings.custom; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; + +import org.schabi.newpipe.R; + +public class SponsorBlockApiUrlPreference extends Preference { + public SponsorBlockApiUrlPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + @Override + protected void onSetInitialValue(@Nullable final Object defaultValue) { + // apparently this is how you're supposed to respect default values for a custom preference + persistString(getPersistedString((String) defaultValue)); + } + + @Nullable + @Override + protected Object onGetDefaultValue(@NonNull final TypedArray a, final int index) { + return a.getString(index); + } + + @Override + protected void onClick() { + super.onClick(); + + final Context context = getContext(); + + final String apiUrl = getPersistedString(null); + + final View alertDialogView = LayoutInflater.from(context) + .inflate(R.layout.dialog_sponsor_block_api_url, null); + + final EditText editText = alertDialogView.findViewById(R.id.api_url_edit); + editText.setText(apiUrl); + editText.setOnFocusChangeListener((v, hasFocus) -> editText.post(() -> { + final InputMethodManager inputMethodManager = (InputMethodManager) context + .getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager + .showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); + })); + editText.requestFocus(); + + alertDialogView.findViewById(R.id.icon_api_url_help) + .setOnClickListener(v -> { + final Uri privacyPolicyUri = Uri.parse(context + .getString(R.string.sponsor_block_privacy_policy_url)); + final View helpDialogView = LayoutInflater.from(context) + .inflate(R.layout.dialog_sponsor_block_api_url_help, null); + final View privacyPolicyButton = helpDialogView + .findViewById(R.id.sponsor_block_privacy_policy_button); + privacyPolicyButton.setOnClickListener(v1 -> { + final Intent i = new Intent(Intent.ACTION_VIEW, privacyPolicyUri); + context.startActivity(i); + }); + + new AlertDialog.Builder(context) + .setView(helpDialogView) + .setPositiveButton("Use Official", (dialog, which) -> { + editText.setText(context + .getString(R.string.sponsor_block_default_api_url)); + dialog.dismiss(); + }) + .setNeutralButton("Close", (dialog, which) -> dialog.dismiss()) + .create() + .show(); + }); + + final AlertDialog alertDialog = + new AlertDialog.Builder(context) + .setView(alertDialogView) + .setTitle(context.getString(R.string.sponsor_block_api_url_title)) + .setPositiveButton("OK", (dialog, which) -> { + final String newValue = editText.getText().toString(); + if (!newValue.isEmpty()) { + final SharedPreferences.Editor editor = + getPreferenceManager().getSharedPreferences().edit(); + editor.putString(getKey(), newValue); + editor.apply(); + + callChangeListener(newValue); + } + dialog.dismiss(); + }) + .setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()) + .create(); + + alertDialog.show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 07d0f516d..b2ff28a6e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -23,6 +23,7 @@ import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.content.Context; +import android.content.SharedPreferences; import android.util.Log; import android.view.View; import android.widget.TextView; @@ -47,6 +48,7 @@ import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.search.SearchInfo; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockApiSettings; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; @@ -110,11 +112,16 @@ public final class ExtractorHelper { }); } - public static Single getStreamInfo(final int serviceId, final String url, + public static Single getStreamInfo(final Context context, + final int serviceId, + final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM, - Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url))); + Single.fromCallable(() -> StreamInfo.getInfo( + NewPipe.getService(serviceId), + url, + buildSponsorBlockApiSettings(context)))); } public static Single getChannelInfo(final int serviceId, final String url, @@ -334,4 +341,49 @@ public final class ExtractorHelper { return text.substring(0, 1).toUpperCase() + text.substring(1).toLowerCase(); } } + + private static @Nullable SponsorBlockApiSettings buildSponsorBlockApiSettings( + final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean isSponsorBlockEnabled = prefs.getBoolean(context + .getString(R.string.sponsor_block_enable_key), false); + + if (!isSponsorBlockEnabled) { + return null; + } + + final SponsorBlockApiSettings result = new SponsorBlockApiSettings(); + result.apiUrl = + prefs.getString(context.getString(R.string.sponsor_block_api_url_key), null); + result.includeSponsorCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_sponsor_key), false); + result.includeIntroCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_intro_key), false); + result.includeOutroCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_outro_key), false); + result.includeInteractionCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_interaction_key), false); + result.includeHighlightCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_highlight_key), false); + result.includeSelfPromoCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_self_promo_key), false); + result.includeMusicCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_non_music_key), false); + result.includePreviewCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_preview_key), false); + result.includeFillerCategory = + prefs.getBoolean(context + .getString(R.string.sponsor_block_category_filler_key), false); + + return result; + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java index 6e9ea7a47..5cccf3585 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -102,7 +102,7 @@ public final class SparseItemUtil { @NonNull final String url, final Consumer callback) { Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); - ExtractorHelper.getStreamInfo(serviceId, url, false) + ExtractorHelper.getStreamInfo(context, serviceId, url, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { diff --git a/app/src/main/java/org/schabi/newpipe/util/SponsorBlockHelper.java b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockHelper.java new file mode 100644 index 000000000..77b7ebf68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockHelper.java @@ -0,0 +1,190 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockCategory; +import org.schabi.newpipe.extractor.sponsorblock.SponsorBlockSegment; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.views.MarkableSeekBar; +import org.schabi.newpipe.views.SeekBarMarker; + +public final class SponsorBlockHelper { + + + private SponsorBlockHelper() { + } + + public static Integer convertCategoryToColor( + final SponsorBlockCategory category, + final Context context + ) { + final String key; + final String colorStr; + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + switch (category) { + case SPONSOR -> { + key = context.getString(R.string.sponsor_block_category_sponsor_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.sponsor_segment) + : Color.parseColor(colorStr); + } + case INTRO -> { + key = context.getString(R.string.sponsor_block_category_intro_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.intro_segment) + : Color.parseColor(colorStr); + } + case OUTRO -> { + key = context.getString(R.string.sponsor_block_category_outro_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.outro_segment) + : Color.parseColor(colorStr); + } + case INTERACTION -> { + key = context.getString(R.string.sponsor_block_category_interaction_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.interaction_segment) + : Color.parseColor(colorStr); + } + case HIGHLIGHT -> { + key = context.getString(R.string.sponsor_block_category_highlight_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.highlight_segment) + : Color.parseColor(colorStr); + } + case SELF_PROMO -> { + key = context.getString(R.string.sponsor_block_category_self_promo_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.self_promo_segment) + : Color.parseColor(colorStr); + } + case NON_MUSIC -> { + key = context.getString(R.string.sponsor_block_category_non_music_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.non_music_segment) + : Color.parseColor(colorStr); + } + case PREVIEW -> { + key = context.getString(R.string.sponsor_block_category_preview_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.preview_segment) + : Color.parseColor(colorStr); + } + case FILLER -> { + key = context.getString(R.string.sponsor_block_category_filler_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.filler_segment) + : Color.parseColor(colorStr); + } + case PENDING -> { + key = context.getString(R.string.sponsor_block_category_pending_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.pending_segment) + : Color.parseColor(colorStr); + } + } + + return null; + } + + public static void markSegments( + final Context context, + final MarkableSeekBar seekBar, + @NonNull final StreamInfo streamInfo + ) { + seekBar.clearMarkers(); + + final SponsorBlockSegment[] sponsorBlockSegments = streamInfo.getSponsorBlockSegments(); + + if (sponsorBlockSegments == null) { + return; + } + + for (final SponsorBlockSegment sponsorBlockSegment : sponsorBlockSegments) { + final Integer color = convertCategoryToColor( + sponsorBlockSegment.category, context); + + // if null, then this category should not be marked + if (color == null) { + continue; + } + + // duration is in seconds, we need milliseconds + final long length = streamInfo.getDuration() * 1000; + + final SeekBarMarker seekBarMarker = + new SeekBarMarker(sponsorBlockSegment.startTime, sponsorBlockSegment.endTime, + length, color); + seekBar.seekBarMarkers.add(seekBarMarker); + } + + seekBar.drawMarkers(); + } + + public static String convertCategoryToFriendlyName(final Context context, + final SponsorBlockCategory category) { + return switch (category) { + case SPONSOR -> context.getString( + R.string.sponsor_block_category_sponsor); + case INTRO -> context.getString( + R.string.sponsor_block_category_intro); + case OUTRO -> context.getString( + R.string.sponsor_block_category_outro); + case INTERACTION -> context.getString( + R.string.sponsor_block_category_interaction); + case HIGHLIGHT -> context.getString( + R.string.sponsor_block_category_highlight); + case SELF_PROMO -> context.getString( + R.string.sponsor_block_category_self_promo); + case NON_MUSIC -> context.getString( + R.string.sponsor_block_category_non_music); + case PREVIEW -> context.getString( + R.string.sponsor_block_category_preview); + case FILLER -> context.getString( + R.string.sponsor_block_category_filler); + case PENDING -> context.getString( + R.string.sponsor_block_category_pending); + }; + } + + public static String convertCategoryToSkipMessage(final Context context, + final SponsorBlockCategory category) { + return switch (category) { + case SPONSOR -> context + .getString(R.string.sponsor_block_skip_sponsor_toast); + case INTRO -> context + .getString(R.string.sponsor_block_skip_intro_toast); + case OUTRO -> context + .getString(R.string.sponsor_block_skip_outro_toast); + case INTERACTION -> context + .getString(R.string.sponsor_block_skip_interaction_toast); + case HIGHLIGHT -> ""; // this should never happen + case SELF_PROMO -> context + .getString(R.string.sponsor_block_skip_self_promo_toast); + case NON_MUSIC -> context + .getString(R.string.sponsor_block_skip_non_music_toast); + case PREVIEW -> context + .getString(R.string.sponsor_block_skip_preview_toast); + case FILLER -> context + .getString(R.string.sponsor_block_skip_filler_toast); + case PENDING -> context + .getString(R.string.sponsor_block_skip_pending_toast); + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SponsorBlockMode.java b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockMode.java new file mode 100644 index 000000000..b6f965816 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockMode.java @@ -0,0 +1,7 @@ +package org.schabi.newpipe.util; + +public enum SponsorBlockMode { + DISABLED, + ENABLED, + IGNORE +} diff --git a/app/src/main/java/org/schabi/newpipe/util/TimeUtils.java b/app/src/main/java/org/schabi/newpipe/util/TimeUtils.java new file mode 100644 index 000000000..1a4d73b16 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/TimeUtils.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.util; + +import java.util.Locale; + +public final class TimeUtils { + private TimeUtils() { + } + + public static String millisecondsToString(final double milliseconds) { + final int seconds = (int) (milliseconds / 1000) % 60; + final int minutes = (int) ((milliseconds / (1000 * 60)) % 60); + final int hours = (int) ((milliseconds / (1000 * 60 * 60)) % 24); + + return String.format(Locale.getDefault(), + "%02d:%02d:%02d", hours, minutes, seconds); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java index 066515d6b..322ae17a5 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java @@ -154,7 +154,7 @@ public final class InternalUrlsHandler { } final Single single = - ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); + ExtractorHelper.getStreamInfo(context, service.getServiceId(), cleanUrl, false); disposables.add(single.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(info -> { diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java index 8176a9aef..2622d521c 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java @@ -24,8 +24,6 @@ import android.view.KeyEvent; import android.view.ViewTreeObserver; import android.widget.SeekBar; -import androidx.appcompat.widget.AppCompatSeekBar; - import org.schabi.newpipe.util.DeviceUtils; /** @@ -33,7 +31,7 @@ import org.schabi.newpipe.util.DeviceUtils; * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to * work with it. */ -public final class FocusAwareSeekBar extends AppCompatSeekBar { +public final class FocusAwareSeekBar extends MarkableSeekBar { private NestedListener listener; private ViewTreeObserver treeObserver; diff --git a/app/src/main/java/org/schabi/newpipe/views/MarkableSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/MarkableSeekBar.java new file mode 100644 index 000000000..82dcda0c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/MarkableSeekBar.java @@ -0,0 +1,109 @@ +package org.schabi.newpipe.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatSeekBar; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipe.R; + +import java.util.ArrayList; + +public class MarkableSeekBar extends AppCompatSeekBar { + public ArrayList seekBarMarkers = new ArrayList<>(); + private Drawable originalProgressDrawable; + + public MarkableSeekBar(final Context context) { + super(context); + } + + public MarkableSeekBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public MarkableSeekBar(final Context context, + final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setProgressDrawable(final Drawable d) { + super.setProgressDrawable(d); + + // stored for when we draw (and potentially re-draw) markers + originalProgressDrawable = d; + } + + @Override + protected void onSizeChanged(final int w, final int h, final int oldW, final int oldH) { + super.onSizeChanged(w, h, oldW, oldH); + + // re-draw markers since the progress bar may have a different width + drawMarkers(); + } + + public void drawMarkers() { + if (seekBarMarkers.size() == 0) { + return; + } + + // Markers are drawn like so: + // + // - LayerDrawable (original drawable for the SeekBar) + // - GradientDrawable (background) + // - ScaleDrawable (secondaryProgress) + // - ScaleDrawable (progress) + // - LayerDrawable (we add our markers in a sub-LayerDrawable) + // - Drawable (marker) + // - Drawable (marker) + // - Drawable (marker) + // - etc... + + final int width = getMeasuredWidth() - (getPaddingStart() + getPaddingEnd()); + + LayerDrawable layerDrawable = (LayerDrawable) originalProgressDrawable; + + final ArrayList markerDrawables = new ArrayList<>(); + markerDrawables.add(layerDrawable); + + for (final SeekBarMarker seekBarMarker : seekBarMarkers) { + @SuppressLint("PrivateResource") + final Drawable markerDrawable = + ContextCompat.getDrawable( + getContext(), + R.drawable.abc_scrubber_primary_mtrl_alpha); + + final PorterDuffColorFilter colorFilter = + new PorterDuffColorFilter(seekBarMarker.color, PorterDuff.Mode.SRC_IN); + + assert markerDrawable != null; + markerDrawable.setColorFilter(colorFilter); + + markerDrawables.add(markerDrawable); + } + + layerDrawable = new LayerDrawable(markerDrawables.toArray(new Drawable[0])); + + for (int i = 1; i < layerDrawable.getNumberOfLayers(); i++) { + final SeekBarMarker seekBarMarker = seekBarMarkers.get(i - 1); + final int l = (int) (width * seekBarMarker.percentStart); + final int r = (int) (width * (1.0 - seekBarMarker.percentEnd)); + + layerDrawable.setLayerInset(i, l, 0, r, 0); + } + + super.setProgressDrawable(layerDrawable); + } + + public void clearMarkers() { + seekBarMarkers.clear(); + super.setProgressDrawable(originalProgressDrawable); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/SeekBarMarker.java b/app/src/main/java/org/schabi/newpipe/views/SeekBarMarker.java new file mode 100644 index 000000000..22a8c5fa6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/SeekBarMarker.java @@ -0,0 +1,26 @@ +package org.schabi.newpipe.views; + +public class SeekBarMarker { + public double startTime; + public double endTime; + public double percentStart; + public double percentEnd; + public int color; + + public SeekBarMarker(final double startTime, + final double endTime, + final long maxTime, + final int color) { + this.startTime = startTime; + this.endTime = endTime; + this.percentStart = ((startTime / maxTime) * 100.0) / 100.0; + this.percentEnd = ((endTime / maxTime) * 100.0) / 100.0; + this.color = color; + } + + public SeekBarMarker(final double percentStart, final double percentEnd, final int color) { + this.percentStart = percentStart; + this.percentEnd = percentEnd; + this.color = color; + } +} diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 000000000..5d3e3f85f --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..909fdd16d --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml new file mode 100644 index 000000000..4edc96a9b --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml new file mode 100644 index 000000000..33d9f56ef --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_rewind.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sponsor_block_disable.xml b/app/src/main/res/drawable/ic_sponsor_block_disable.xml new file mode 100644 index 000000000..5965ef4c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_sponsor_block_disable.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_sponsor_block_enable.xml b/app/src/main/res/drawable/ic_sponsor_block_enable.xml new file mode 100644 index 000000000..7bd3d91ce --- /dev/null +++ b/app/src/main/res/drawable/ic_sponsor_block_enable.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_sponsor_block_exclude.xml b/app/src/main/res/drawable/ic_sponsor_block_exclude.xml new file mode 100644 index 000000000..779a400f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_sponsor_block_exclude.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml new file mode 100644 index 000000000..cf996d197 --- /dev/null +++ b/app/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,10 @@ + + + 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 index a5df5e566..ec00c01ad 100644 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -280,7 +280,7 @@ tools:ignore="HardcodedText" tools:text="1:06:29" /> - - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_sponsor_block_api_url_help.xml b/app/src/main/res/layout/dialog_sponsor_block_api_url_help.xml new file mode 100644 index 000000000..b803c9143 --- /dev/null +++ b/app/src/main/res/layout/dialog_sponsor_block_api_url_help.xml @@ -0,0 +1,25 @@ + + + +